Skip to content

pilotctl: render next-steps after every appstore call#396

Merged
Alexgodoroja merged 4 commits into
mainfrom
feat/next-steps-graph
Jul 16, 2026
Merged

pilotctl: render next-steps after every appstore call#396
Alexgodoroja merged 4 commits into
mainfrom
feat/next-steps-graph

Conversation

@Alexgodoroja

@Alexgodoroja Alexgodoroja commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

What & why

Autonomous agents install an app and stop caring, because nothing in a call's output tells them where the flow goes. The product demo covers install→first-call, once. This covers every call after it: each pilotctl appstore call now ends by naming the small set of recommended next commands for where the agent stands — the next step on success, and the specific fix on failure (needs_signup → the gateway, 402 → balance, missing param → a corrected call).

This is the client half. The authoring half (schema, CI gates, 19 backfilled graphs) is app-template#89 (merged). This PR is inert until an app's metadata.json carries a next_steps — nothing regresses when the field is absent, which is every app today.

This PR is pilotctl code only — no catalogue changes. (The aegis catalogue republish that briefly rode here has been removed; it now lives in #398. This branch touches only cmd/pilotctl/.)

Deploy note: client code ships when a v* tag is cut and pilot-updater rolls it out — not on merge.

Design

  • No network on the call path — the graph is cached to $APP/next-steps.json at install from the already-sha-verified metadata.json; every call is a local read (path-confined via resolveUnder after gosec flagged the raw join as G703).
  • stderr, never stdout — stdout is the JSON agents pipe to jq; --json callers get a structured next_steps object.
  • Matching is over the outcome PAYLOAD — error text on failure, result body on success. The requireKey soft-fail is exit 0 with {"needs_signup":true}, so an outcome-only matcher would stay silent for the cold agent who most needs the gateway named.
  • Precedence: an edge that matched the situation beats one that merely matched the method name — a regression fix (the flow edge was shadowing the gateway). Kept in lockstep with app-template's resolver by TestResolveMatchesAppTemplateSemantics; drift caught by a mutation test.
  • Fail-silent, recover()-guarded, PILOT_NEXT_STEPS=off opt-out.

Verification

  • go test ./cmd/pilotctl/ green; gofmt/go vet/gosec clean.
  • Proven end-to-end against the live daemon with the real authored graphs: a cold primitive.send_email renders the signup gateway marked (required first); a real sqlite.query missing-param failure renders the fix, and the recommended command then works.
  • Hardened on a fresh node with adversarial graphs: malformed/oversized/binary/symlink/traversal/ReDoS((a+)+$, RE2-linear no hang)/2000-edge inputs all fail safe; stdout stays pure JSON; latency overhead negligible.

Alexgodoroja added 2 commits July 15, 2026 14:06
An agent installs an app and stops caring: nothing in a call's output says
where the flow goes next. The product demo covers install->first-call, once.
This covers every call after it.

Each `appstore call` now ends by naming the small set of recommended next
commands for where the agent stands: the next step in the flow on success,
and the specific fix on failure (402 -> top up, needs_signup -> the gateway,
missing param -> a corrected call).

The graph is authored in submission.json, rides into the catalogue's
metadata.json (pinned by metadata_sha256 under the catalogue signature), and
is cached at install to $APP/next-steps.json. The call path is therefore a
local file read: no fetch, no DNS, no added latency, works offline.

Matching is over the outcome PAYLOAD, not just the outcome: the error text on
failure, the result body on success. That is load-bearing. The scaffold's
requireKey wrapper soft-fails an unauthenticated call with exit 0 and
{"needs_signup":true,...}, so "you must sign up first" is not an error at all
-- an outcome-only matcher would stay silent for precisely the cold agent who
most needs the gateway named.

Output goes to stderr, never stdout: stdout is the JSON result agents pipe
into jq, and one line of prose there breaks the workflow this exists to
encourage. --json callers get a structured next_steps object instead.

Additive and fail-silent throughout: absent, malformed, future-schema or
unmatched means the call behaves exactly as before. A hint is a nicety; a
call is not.

- appstore_nextsteps.go: mirrored wire contract, resolver, renderer, cache
- appstore.go: hooks on both outcomes; success resolves before the review
  prompt can replace the body
- main.go: fatalHint drains exitNextSteps so steps land after the error they
  fix (same pattern motd's importantUpdate already uses)
- PILOT_NEXT_STEPS=off opts out
Ports the precedence fix from app-template's nextsteps.Resolve, which validates
the graphs this resolves. The two had drifted: this side still ranked from-exact
above a match, so for any method with both a flow edge and a soft-failing
gateway, the flow edge won — a cold agent calling primitive.send_email was told
to read its inbox instead of to sign up.

The parity test passed through the drift because it never covered exact-bare vs
wildcard-match, which is precisely the shadowing case. Added it; verified by
mutation that re-introducing the old scoring now fails the suite.

Proven end-to-end against the real app with the real authored graph: a cold
send_email now renders the signup gateway, marked (required first).
Comment thread cmd/pilotctl/appstore_nextsteps.go Fixed
Comment thread cmd/pilotctl/appstore_nextsteps.go Fixed
gosec flagged both ends as G703 path traversal, correctly. appID arrives
straight from argv, so filepath.Join alone is not containment:
`pilotctl appstore call ../../etc/x ...` resolves outside the install root and
we would read a file there and print pieces of it back as "next steps".

Use resolveUnder on both the read and the write — the same guard install and the
supervisor already rely on, whose own comment says filepath.Join does NOT block
traversal.

The regression test needed a second pass. Its first draft passed even with the
naive join restored: the graph's app-id-mismatch check rejected the planted file
first, so the test was green for the wrong reason and proved nothing. The
planted graph's `app` now equals the traversing id, leaving containment as the
only thing that can stop the load — verified by mutation that removing the guard
fails the suite.
@Alexgodoroja Alexgodoroja changed the title pilotctl: render next-steps after every appstore call pilotctl: next-steps after every appstore call + aegis catalogue republish Jul 16, 2026
Alexgodoroja pushed a commit that referenced this pull request Jul 16, 2026
…consolidated catalogue

aegis 0.1.3's live bundle has the cli.args argv bug that killed 6 of 7 methods.
Adopt the rev2 rebuild: bundle_url -> .../0.1.3/rev2/..., new bundle_sha256/size
and per-platform bundles map, metadata size updated, aegis metadata_sha256
recomputed, catalogue re-signed. Verified all 3 rev2 platform bundles resolve
(HTTP 200) and their sha256 match. The aegis product_demo (already present) is
byte-identical to #389/#396. Consolidates the aegis catalogue content of #389
(demo + go-live) and #396 (rev2 fix).
@Alexgodoroja
Alexgodoroja force-pushed the feat/next-steps-graph branch from 4ec90ce to dfc4e76 Compare July 16, 2026 19:43
@Alexgodoroja Alexgodoroja changed the title pilotctl: next-steps after every appstore call + aegis catalogue republish pilotctl: render next-steps after every appstore call Jul 16, 2026
Alexgodoroja added a commit that referenced this pull request Jul 16, 2026
* catalogue: backfill product demos into all 19 apps' metadata (signed)

Inject the example-driven `product_demo` (authored in the app-template
submissions, app-template#88) into each app's store-page metadata.json, so that
`pilotctl appstore install` (pilotprotocol#395) prints the usage banner and the
website renders the "Full usage demo". This is the DATA half of the feature —
#395 is the renderer and is inert until this lands.

- 18 apps: product_demo appended to the existing catalogue/apps/<id>/metadata.json
  (byte-preserving append; only the new key is added).
- io.telepat.ideon-free had no rich metadata page (no metadata_url); created one
  from its submission via BuildMetadata, and added metadata_url + metadata_sha256
  to its catalogue entry.
- io.pilot.sixtyfour's demo is injected here at the metadata layer (its
  third-party-owned app-template submission is under the update gate and could
  not carry the demo).
- catalogue.json: every touched app's metadata_sha256 recomputed; catalogue.json
  re-signed (catalogue.json.sig) with the catalogue key. Verified: every
  metadata_url entry's sha matches its file and the signature validates.

Additive + non-breaking: product_demo is an optional metadata field; clients
that don't render it ignore it. Merging deploys instantly (metadata_url is raw
main).

* catalogue: consolidate — demos for all 23 installable apps + smol tombstone (signed once)

Fold the six sibling catalogue PRs into this one so the catalogue is signed and
deployed once instead of seven times:

- Product demos for the 4 apps that still lacked one — cosift, primitive,
  wallet, slipstream — authored against each app's real method set (validated by
  internal/demo). All 23 installable apps now carry a product_demo.
- Consistency fixes: wallet's metadata had NO methods array (now its 16 methods);
  slipstream had NO metadata page at all (created one). Every installable entry
  is now schema_version 1 with methods + a demo whose skill == the app id.
- Demos for agentphone / bowmark / didit / docker / duckdb (PRs #390#394) are
  already present here — byte-identical — with main's richer entry metadata kept
  (the sibling PRs carried unenriched entries that would have regressed it).
- Smol rename collapse (from #360): io.pilot.smolmachines replaced with a
  tombstone (renamed_to io.pilot.smol, hidden), its detail page removed, and the
  "Renaming an app" convention documented in catalogue/README.md.
- Every metadata_sha256 recomputed; catalogue.json re-signed once. Verified: all
  23 metadata_url entries' shas match, the tombstone is well-formed, and the
  signature validates against the catalogue trust anchor.

* catalogue: fold aegis rev2 bundle (argv fix) from #396/#389 into the consolidated catalogue

aegis 0.1.3's live bundle has the cli.args argv bug that killed 6 of 7 methods.
Adopt the rev2 rebuild: bundle_url -> .../0.1.3/rev2/..., new bundle_sha256/size
and per-platform bundles map, metadata size updated, aegis metadata_sha256
recomputed, catalogue re-signed. Verified all 3 rev2 platform bundles resolve
(HTTP 200) and their sha256 match. The aegis product_demo (already present) is
byte-identical to #389/#396. Consolidates the aegis catalogue content of #389
(demo + go-live) and #396 (rev2 fix).

* catalogue: fix ground-truth mistakes in cosift/wallet/slipstream metadata

Reviewed the 4 hand-authored demos against each app's real API. Fixes:
- cosift: params were wrong — search/answer/research take "q" (not "query")
  and contents takes "url"/"urls" (not "id"). Verified against the
  cosift-app adapter (cmd/cosift-app/main.go methodTable) + cosift docs/API.md.
- wallet: it exposes NO wallet.help method — removed the phantom method from the
  metadata list (15 real methods) and from the demo's next. wallet.balance takes
  {"asset":"USDC"}. Method summaries corrected against pkg/walletipc source.
- slipstream: replaced hand-written method summaries with the authoritative ones
  from the live slipstream.help (installed + queried the app); demo args ({})
  confirmed valid (all list params optional).
- primitive: verified clean (all 5 demo methods exist, take no required params).
Recomputed the 3 metadata_sha256 and re-signed the catalogue.

* catalogue: add next_steps dependency graphs to all 19 apps (re-signed)

Folds the dynamic-context dependency graphs into this consolidated catalogue PR
so the whole rollout is one signature, not a second re-sign after a separate
merge. Each app's next_steps is copied verbatim from its app-template
submission (the same object BuildMetadata would emit) into
catalogue/apps/<id>/metadata.json, its metadata_sha256 re-pinned, and
catalogue.json re-signed.

18 apps gain next_steps here; io.telepat.ideon-free already carried a matching
graph. Purely additive to each metadata.json (+next_steps, nothing else
touched); aegis's rev2 argv-fix bundle pin is unchanged.

Verified: every metadata_sha256 pin matches its file bytes, every graph equals
its submission source, and the re-signed catalogue.json.sig verifies against the
embedded catalogue public key (and rejects tampering).

* catalogue: add next_steps graphs to the 5 first-party apps (re-signed)

Completes dynamic-context coverage: cosift, wallet, sixtyfour and slipstream
gain a next_steps graph here (authored from each app's own source + live testing
where free — cosift's flow was exercised end-to-end; sixtyfour used help only,
no metered calls; wallet/slipstream from their repos/metadata). Every step is a
full `pilotctl appstore call ...` command.

io.pilot.smolmachines is intentionally NOT covered: it is a hidden, non-installable
tombstone (renamed_to io.pilot.smol) with no metadata.json, so there is nothing to
attach a graph to and nothing can call it. That leaves 23/24 catalogue entries
with a graph — every real, installable app.

Each metadata.json gains only next_steps; metadata_sha256 re-pinned and
catalogue.json re-signed. Verified: all pins match, signature valid.

* catalogue: fix io.pilot.postgres links (object -> array) so `appstore view` parses

Pre-existing data bug, surfaced while verifying that the added product_demo/next_steps
fields don't break parsers: postgres's `links` was a JSON object
{"documentation":...,"psql_reference":...} but appMetadata.Links is []{label,url}
(the shape every other app uses). `pilotctl appstore view io.pilot.postgres` failed
to unmarshal the detail metadata on BOTH the released client and this one — unrelated
to next_steps. Converted to the array form; re-pinned + re-signed.

Authoritative typed-parse over all 23 metadata files now passes; product_demo and
next_steps parse cleanly everywhere (Go ignores unknown fields — no client strictness).

---------

Co-authored-by: Alexgodoroja <alex@vulturelabs.io>
TeoSlayer
TeoSlayer previously approved these changes Jul 16, 2026

@TeoSlayer TeoSlayer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏

# Conflicts:
#	cmd/pilotctl/appstore_metadata.go
@Alexgodoroja
Alexgodoroja merged commit 3964b9f into main Jul 16, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants