Skip to content

walkthrough_picker_to_first_query golden predates compose_with riders (#750) - #757

Merged
philcunliffe merged 2 commits into
masterfrom
fix/issue-750
Aug 14, 2026
Merged

walkthrough_picker_to_first_query golden predates compose_with riders (#750)#757
philcunliffe merged 2 commits into
masterfrom
fix/issue-750

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #750.

Which side was wrong: the golden

The picker is correct. goldenPickerConfig is stale.

LLP 0213 #d1 (Accepted, 2026-08-12) decides it in as many words:

The walkthrough composes @hypaware/context-graph and @hypaware/ai-gateway-graph as a pair, wherever the AI gateway is composed.
The pairing is declared, not hardcoded. The graph plugins name their condition in their own manifests (a compose_with declaration), and core composes any bundled plugin whose named plugins are all composed.

Both manifests carry exactly that:

hypaware-core/plugins-workspace/ai-gateway-graph/hypaware.plugin.json:11:  "compose_with": ["@hypaware/ai-gateway"],
hypaware-core/plugins-workspace/context-graph/hypaware.plugin.json:11:  "compose_with": ["@hypaware/ai-gateway"],

So a Phase 5 config that picks the gateway is supposed to carry both. Option 2 in the issue (a graph plugin appearing unasked is the defect) is ruled out: appearing unasked is the decision. LLP 0213 names it a derived-data plugin, a fifth emergent category that "contributes no pick of its own, and rides the pick whose data it derives from", and LLP 0011 #no-architectural-names is cited as the reason it is deliberately not a picker row: the user says what to collect and HypAware picks the plugin set.

The commit that caused the divergence

5b13456 "Reduce the client skill surface to three; always activate the graph (#720)" (Brendan McMullen, 2026-08-12).

How I know:

  • It is the only commit in the tree that introduces compose_with (git log -S"compose_with" -- src/ hypaware-core/ test/ returns exactly one commit), and the only commit touching llp/0213-graph-plugin-always-active.decision.md.
  • It added +153 lines to src/core/cli/walkthrough.js, which is where ridersFor and the for (const rider of ridersFor(plugins, args.composeWith)) plugins.push({ name: rider }) line at the end of composePickerConfig now live.
  • git show --stat 5b13456 | grep -i "smoke\|walkthrough" matches only src/core/cli/walkthrough.js. It changed no smoke flow.

That is the indirection the issue predicted. Neither goldenPickerConfig nor bundled.js shows the change in git log, because the new plugins entered through manifest edits plus a new fold in the composer. V1_BUNDLED_PLUGIN_ALLOWLIST is a red herring: it never governed the picker, only the all-bundled / all-available boot profiles. Membership in it is necessary (loadPickerCatalog filters riders through ridersInDefaultSet) but not sufficient, which is why eleven other allowlisted plugins are still correctly absent from the written config.

What changed

One file, hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js.

Per the issue's warning against pasting in two lines: the golden now derives the rider tail and keeps the literal half for what the picker itself decides.

  • goldenPickerConfig still lists the six picked plugins literally, with the upstreams, the OTLP host/port, the sink shape, and retention verbatim. That is the part the picker composes from its rows, and it stays a golden.
  • A new composedRiders(picked) reads the bundled manifests and, run to a fixpoint mirroring ridersFor, returns those whose compose_with names are, transitively, all in the picked set (a rider whose compose_with names another rider still lands). A new derived-data plugin, including one that rides another rider, is now a manifest edit, not a re-red smoke.

On the "a derived expectation that mirrors the implementation stops testing anything" risk, three things keep it honest:

  1. It does not call composePickerConfig or ridersFor. It reads the manifest declarations and applies the rule to a literal picked set, so the composer is still the code under test.
  2. It is still an exact deepEqual over the whole config. A plugin that is neither picked nor a declared rider fails the assertion, which is the "graph plugin appears unasked" defect the issue worried about, still caught.
  3. The rider mechanism (the fixpoint, the unmet-condition case, the reconfigure/stranding case) keeps its unit coverage in test/core/compose-picker-config.test.js, which LLP 0213 #consequences designates as its home. The smoke does not need to re-derive policy that has unit tests.

composedRiders reads only discoverBundledPlugins().loaded, never excluded. That reproduces the default-activation boundary ridersInDefaultSet enforces (a compose_with must not be a route around an explicit opt-in such as an API-backed embedder) without restating V1_EXCLUDED_FROM_DEFAULT a second time.

No LLP change: this implements a decision already Accepted in 0213 and changes no documented contract.

Full release battery

All 13 flows from CLAUDE.md, on this branch, Linux, fresh npm install:

flow result time
package_bin_boot FAIL (pre-existing, unrelated, see below) 270 ms
cli_bundled_plugins_activated pass 316 ms
daemon_foreground_start_stop pass 262 ms
daemon_install_render pass 242 ms
walkthrough_picker_to_first_query pass (was the subject of this issue) 403 ms
client_attach_idempotent pass 352 ms
gateway_claude_capture pass 486 ms
gateway_codex_capture pass 472 ms
hypignore_capture_drop pass 487 ms
local_only_export_withhold pass 326 ms
otel_loopback_capture pass 479 ms
local_parquet_export pass 482 ms
status_diagnostics pass 293 ms

So #750 was not the only red flow, which the issue explicitly left open.

package_bin_boot is a second, separate rot (not fixed here)

Same shape as this issue, different commit, different smoke. Out of scope, deliberately not touched, worth its own issue:

hypaware-core/smoke/flows/package_bin_boot.js:68 asserts the help output .includes('hyp - HypAware kernel CLI'). src/core/cli/dispatch.js:773 now writes:

hyp - HypAware: your AI agents' sessions, logs, and telemetry in one queryable history

git log -S"HypAware kernel CLI" -- src/ bin/ points at b9e50ee "Onboarding copy overhaul: explain the product, lead with shared collection (LLP 0211) (#711)", which rewrote the tagline and did not update the smoke that pins it. Confirmed pre-existing: it fails identically with this branch's change stashed. The fix is presumably to assert the stable hyp - HypAware prefix rather than a marketing sentence that is expected to change.

Checks

  • npm test - pass (4024 passing, 0 failing, 1 skipped)
  • npm run typecheck - pass (clean)
  • npm run smoke -- walkthrough_picker_to_first_query - ok

Recommendation on CI (not implemented, for a human to decide)

The issue's second half is the more valuable one, and I have deliberately not implemented a CI change. Here is the concrete tradeoff, with the cost measured rather than guessed.

Today .github/workflows/ runs test (22), test (24), typecheck (22), typecheck (24) and duplicate-numbers. No smoke runs anywhere.

The cost argument is weaker than it looks. The entire 13-flow battery runs in about 4.9 seconds. Slowest flow 487 ms, whole battery under five seconds wall clock, against a job that already spends far longer on checkout and npm ci. There is no per-flow triage to do: nothing here is slow.

Nor is there an isolation problem. Every one of these is a hermetic smoke by construction: temp HYP_HOME, HYP_DEV_TELEMETRY=1, loopback-only ports, no network egress, no real daemon install, no touching the runner's home. They are exactly the tier CLAUDE.md describes as "good for PR confidence and plugin/kernel wiring checks".

Recommendation: run the full battery as one job on every PR, single Node version, ubuntu-latest. Not a matrix: these exercise wiring, not language-version behaviour, and the existing test/typecheck matrix already covers 22 and 24.

Two caveats a human should weigh:

  1. package_bin_boot is red right now. Making the battery a required check either waits on that fix or lands with it. Otherwise the new job blocks every PR on day one.
  2. daemon_install_render renders systemd on Linux and launchd on macOS, so a Linux-only job leaves the launchd path unguarded. A macos-latest leg on a nightly schedule (not per-PR) would close that at low cost. Everything else is platform-agnostic.

What this does not change: CLAUDE.md is explicit that hermetic smokes are not the release gate, and acceptance smokes (docs/ACCEPTANCE.md) stay a human step. Putting the battery in CI does not weaken that; it just means the next rot is caught by a machine on the commit that causes it rather than by a passer-by two PRs later, which is what happened here twice.

🤖 Generated with Claude Code

…#750)

The Phase 5 golden config still lists the six plugins the picker rows
compose, but `hyp init` has written eight since #720 landed LLP 0213 D1:
`@hypaware/context-graph` and `@hypaware/ai-gateway-graph` declare
`compose_with: ["@hypaware/ai-gateway"]` and ride the gateway pick.

The picker is right and the golden is stale. #720 added the rider fold to
`composePickerConfig` (+153 lines in `src/core/cli/walkthrough.js`) and
touched no smoke flow, which is why neither `goldenPickerConfig` nor
`bundled.js` shows the change in `git log`: the divergence arrived through
the manifests.

Rather than paste the two names in, the golden derives its rider tail from
the same declarations the composer reads, so the next derived-data plugin
is a manifest edit rather than a re-red smoke. The literal half is what the
picker actually decides: which rows compose, the upstreams, the OTLP port,
the sink shape, retention. The derivation reads manifests, not
`ridersFor`, so a plugin that is neither picked nor declared a rider still
fails the assertion; the rider mechanism itself keeps its unit coverage in
`test/core/compose-picker-config.test.js`.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review - round 1

Head reviewed: 8bcc18c77b5f5d07ad2ae76565fdfb92097b0784. All 9 checks SUCCESS.
Reviewed in a detached worktree; nothing was written to the branch.

The reviewer ran a mutation table rather than reading the new expectation and
judging it plausible, which is the only way to answer the question this fix raises:
a golden that derives from the same declarations the implementation reads can
degrade into a tautology. Eleven mutations, ten caught. The one blind spot is
designed and covered elsewhere. Details below.


Cleanup done, worktree clean. Here is the review.

VERDICT: findings

1. minor — hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js:585-598 (and the PR body's fourth paragraph)

composedRiders is a single pass over the manifests; ridersFor (src/core/cli/walkthrough.js:1074-1092) runs to a fixpoint. present is seeded from the picked six and never grows, so a rider whose compose_with names another rider is composed by the implementation and absent from the golden.

Verified: setting hypaware-core/plugins-workspace/hermes/hypaware.plugin.json to "compose_with": ["@hypaware/context-graph"] — a manifest-only edit, no code touched, which ridersFor handles by design and which test/core/compose-picker-config.test.js already has a green test for ("a rider that rides another rider still composes") — turns this smoke red (M7 in the table).

Why it matters: this is exactly the case the PR was built to avoid. The commit message says "the next derived-data plugin is a manifest edit rather than a re-red smoke", and the helper's own JSDoc at line 557 describes the rule as "a manifest whose compose_with names only plugins already composed" — which is the fixpoint rule, not what the code does. The claim holds for first-order riders only. The direction is fail-safe (a false red, not a false green), hence minor rather than major.

Exact fix — make the helper transitive, matching ridersFor:

async function composedRiders(picked) {
  const { loaded } = await discoverBundledPlugins()
  const present = new Set(picked)
  /** @type {string[]} */
  const riders = []
  let grew = true
  while (grew) {
    grew = false
    for (const { manifest } of loaded) {
      const waitsFor = manifest.compose_with
      if (!Array.isArray(waitsFor) || waitsFor.length === 0) continue
      if (present.has(manifest.name)) continue
      if (!waitsFor.every((name) => present.has(name))) continue
      present.add(manifest.name)
      riders.push(manifest.name)
      grew = true
    }
  }
  return riders
}

(This is still not a call into ridersFor, so the author's "does not run the code under test" property survives; M3b and M4b below confirm the two copies of the rule are genuinely independent.) If you would rather not carry the loop, the alternative fix is to delete the "already composed" wording from line 557 and the "next derived-data plugin is a manifest edit" sentence from the commit message, and say the golden covers first-order riders only.

2. nit — hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js:576-579

"discoverBundledPlugins puts allowlisted plugins there and the explicit-opt-in ones in excluded, which is the same cut ridersInDefaultSet makes on the composer." It is not the same cut. loaded is membership of V1_BUNDLED_PLUGIN_ALLOWLIST; ridersInDefaultSet is non-membership of V1_EXCLUDED_FROM_DEFAULT (src/core/cli/walkthrough.js:2083-2091). Two separate lists, not complements. They agree today only because those lists happen to partition the whole workspace — I confirmed discoverBundledPlugins() returns 13 loaded, 9 excluded, unknownDirs: [] — and because loadPickerCatalog builds its catalog from loaded + excluded, so a plugin in neither list is invisible to both sides anyway. The conclusion holds; the stated reason does not. Fix: replace with "…which agrees with the cut ridersInDefaultSet makes, because the allowlist and the excluded set together cover every bundled plugin and loadPickerCatalog reads only those two buckets."

What the new expectation still catches

All mutations applied to the head commit's worktree, npm run smoke -- walkthrough_picker_to_first_query, reverted with git checkout after each.

# Mutation Where Smoke
baseline none green (0.42s)
pre-fix restore HEAD~1's golden (master's state) smoke flow red (the bug #750 reports is real)
M1 picked plugin dropped from the picker's output (postExportPlugins.slice(1), drops @hypaware/claude) walkthrough.js:1010 red
M2 extra plugin pushed into the config with no compose_with declaration (@hypaware/hermes) walkthrough.js:1013 red
M3a control: rider declares compose_with: [ai-gateway, openclaw], openclaw unpicked hermes manifest green (correctly not composed)
M3b …plus ridersFor's .every weakened to .some, so the rider rides an unpicked plugin manifest + walkthrough.js:1085 red
M4a control: a picked plugin also declares compose_with (@hypaware/otel) otel manifest green (correctly not duplicated)
M4b …plus ridersFor's already-present guard weakened, so the picked plugin is appended again manifest + walkthrough.js:1083 red
M5 rider order reversed (return added.reverse()) walkthrough.js:1091 red (deepEqual is order-sensitive on arrays)
M6 compose_with typo (@hypaware/ai-gatway) in the context-graph manifest context-graph manifest green — blind spot, see note
M7 second-order rider: hermes compose_with: ["@hypaware/context-graph"], no code change hermes manifest red — false red, finding 1
M8 rider fold deleted entirely (LLP 0213 D1 regression) walkthrough.js:1012 red
M9 excluded plugin smuggled as a rider: gascity declares compose_with, ridersInDefaultSet filter removed from the shipped resolvePickSeeding path gascity manifest + pick.js:86 red
M10 literal half: retentionDays + 1 walkthrough.js:1020 red

On M6, the one real blind spot. Because the golden reads the same declarations the composer does, a manifest-level change to compose_with moves both sides together and the smoke stays green — a typo that silently drops @hypaware/context-graph from every new install is invisible here. That is the designed consequence of "derived, not restated", and it is not a coverage regression (the smoke was red before this PR, so it asserted nothing at all). It is caught elsewhere: with the typo applied, node --test test/core/compose-picker-config.test.js fails 6 of 31, because the rider block asserts GRAPH_PLUGINS by name against the real catalog. LLP 0213 #consequences designates that file as where D1's mechanism earns its coverage, so the split is the one the decision asked for. Nothing was moved out of the smoke into a weaker home; the unit block predates this PR (it landed with #720).

The residual symmetric-degradation risk is narrow and I could not induce it: if discoverBundledPlugins hit ENOENT on the workspace, composedRiders returns [] and loadPickerCatalog's catch also degrades to no riders, so both sides would agree on a six-plugin config. The smoke runs from the repo, so the workspace is always present.

Also checked, clean

  • LLP 0213 #d1 (Accepted, 2026-08-12), read in full. It decides exactly what the author reports: the walkthrough composes @hypaware/context-graph and @hypaware/ai-gateway-graph "as a pair, wherever the AI gateway is composed"; "the pairing is declared, not hardcoded" via a compose_with manifest declaration, explicitly rejecting a branch in composePickerConfig on LLP 0130 #consequences grounds; and #derived-data-plugins names the derived-data category that "contributes no pick of its own and rides the pick whose data it derives from", citing LLP 0011 #no-architectural-names as "precisely why this is not a picker row". LLP 0011 carries the matching amendment banner (lines 27-33): "#no-architectural-names is unchanged, and is why such a plugin is not given a picker row of its own." The picker is correct and the golden was stale. This PR fixes the right side.
  • Both manifests carry the declaration: context-graph/hypaware.plugin.json:11 and ai-gateway-graph/hypaware.plugin.json:11, each ["@hypaware/ai-gateway"]. They are the only two in the workspace.
  • Allowlist membership is necessary but not sufficient, as claimed. ridersInDefaultSet (walkthrough.js:2083) drops every rider in V1_EXCLUDED_FROM_DEFAULT, and it is applied on the shipped path inside resolvePickSeeding (src/core/cli/wizard/pick.js:86), not only in loadPickerCatalog. M9 confirms the smoke would catch a regression there.
  • Rider unit coverage is real, not nominal. test/core/compose-picker-config.test.js:364-636 runs against the shipped manifests (realCatalog()) and pins: the pair composed with a gateway pick by name; neither composed on a gateway-free pick; both dropped by a reconfigure that unpicks the gateway; a hand-added non-rider surviving that reconfigure; the second-order fixpoint; no-composeWith-means-no-riders; a user's enabled: false on a rider surviving; an excluded plugin's compose_with being filtered, both at the filter and through resolvePickSeeding; and a corpus guard that no excluded bundled manifest declares compose_with at all. This is a stronger home for the mechanism than the smoke.
  • Attribution checks out. 5b13456 is "Reduce the client skill surface to three; always activate the graph (Reduce the client skill surface to three; always activate the graph #720)", src/core/cli/walkthrough.js | 153 +++, and it touched no file under hypaware-core/smoke/.
  • package_bin_boot (package_bin_boot is red on master: the --help banner changed and the smoke still pins the old copy #758) is untouched and not papered over. It is the only other red in the battery; failure is package_bin_boot.js:66-69 expecting 'hyp - HypAware kernel CLI' while the banner now reads hyp - HypAware: your AI agents' sessions, logs, and telemetry in one queryable history. This PR changes one file and it is not that one.
  • Battery, flow by flow, on the head commit: package_bin_boot RED (pre-existing, package_bin_boot is red on master: the --help banner changed and the smoke still pins the old copy #758); cli_bundled_plugins_activated, daemon_foreground_start_stop, daemon_install_render, walkthrough_picker_to_first_query, client_attach_idempotent, gateway_claude_capture, gateway_codex_capture, hypignore_capture_drop, local_only_export_withhold, otel_loopback_capture, local_parquet_export, status_diagnostics all GREEN. 12/13, matching the author. Full battery wall time 4.69s, matching the reported ~4.9s.
  • npm test: 4025 tests, 4024 pass, 1 skipped, 0 fail (15.9s), including llp-ref-hygiene, so the new @ref LLP 0213#d1 [tests] resolves and is attached (JSDoc closes at line 584, function at 585, no blank line).
  • npm run typecheck (tsc -p tsconfig.json --noEmit): clean.
  • Conventions: no U+2014 anywhere in the changed file; no trailing semicolons; no @typedef; no inline import('...') types; the one new import is a repo-relative .js runtime import matching the file's existing block. The helper sits in the file's helper region directly above its only caller, goldenPickerConfig, which is the file's idiom; making that function async is propagated at its single call site (line 244).
  • Fresh npm install run in the worktree. git status clean at finish; the only scratch file I created was removed.

…#750)

composedRiders was single-pass while ridersFor runs to a fixpoint, so a
rider whose compose_with names another rider (rather than a picked
plugin) was composed by the implementation but missing from the
golden, false-redding the smoke on a manifest-only edit. Run the same
manifest set to a fixpoint, mirroring ridersFor, so second-order
riders land in the golden too.

Also corrects a comment that claimed discoverBundledPlugins's loaded
bucket is "the same cut" ridersInDefaultSet makes on the composer.
It is not the same cut (allowlist membership vs. exclude-set
non-membership); they only agree because those two lists partition
the whole bundled workspace and loadPickerCatalog reads only them.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review - round 2 (final)

Head reviewed: 2883a70. All 9 checks SUCCESS. Reviewed in a detached worktree;
nothing was written to the branch.

Both round-1 findings are genuinely fixed. The ordering argument, which is the one
thing the fixpoint change could have got wrong on an order-sensitive deepEqual,
survived five hand-constructed cases and an 8000-case randomized differential
feeding the same random compose_with graphs to the real ridersFor and to a copy
of the smoke helper: zero divergences.

One nit, not ship-blocking, and it is the same defect class as the nit it replaced:
a true conclusion resting on the wrong reason. The corrected comment justifies
agreement by coverage when the load-bearing property is disjointness. The
reviewer showed coverage can hold while agreement breaks (put a name in both
V1_BUNDLED_PLUGIN_ALLOWLIST and V1_EXCLUDED_FROM_DEFAULT and the smoke goes red
on a plugin the install never gets), and that the real invariant is guarded by no
test. Worth noting the commit message got it right and only the JSDoc dropped the
load-bearing half.


Cleanup done (my three scratch files removed; the other scratchpad contents predate this session). Worktree clean at 2883a70. Here is the review.

VERDICT: findings

Nothing is ship-blocking. Both round-1 findings are genuinely fixed, I could not break the ordering argument in five constructed cases plus 8000 randomized graphs, and the ten mutations round 1 caught are still caught. The one finding below is a comment-accuracy nit.

1. nit — hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js:581-584 (not ship-blocking)

The replacement wording for round-1 finding 2 states a premise that is neither necessary nor sufficient for the conclusion it justifies:

 * Reading only `loaded` keeps the default-activation boundary without
 * restating it: it agrees with the cut `ridersInDefaultSet` makes, because
 * the allowlist and the excluded set together cover every bundled plugin and
 * `loadPickerCatalog` reads only those two buckets.

The two pools are: helper = loaded; implementation = (loaded ∪ excluded) \ V1_EXCLUDED_FROM_DEFAULT. The excluded bucket is by construction exactly the members of V1_EXCLUDED_FROM_DEFAULT, so it drops out either way. What makes the pools equal is that nothing in loaded is in V1_EXCLUDED_FROM_DEFAULT, i.e. the allowlist and the excluded set are disjoint. Coverage (their union being every bundled plugin) does no work in either direction:

  • Coverage can fail without breaking agreement: a bundled plugin in neither list lands in unknownDirs, so it is absent from loaded (helper blind) and absent from buildPluginCatalog([...loaded, ...excluded]) (implementation blind). Both sides ignore it.
  • Coverage can hold while agreement breaks: I added '@hypaware/hermes' to V1_EXCLUDED_FROM_DEFAULT (leaving it in the allowlist) plus compose_with: ["@hypaware/ai-gateway"] on its manifest. Union still covers everything; discoverBundledPlugins checks the allowlist first so hermes stays in loaded, the helper composes it, ridersInDefaultSet drops it, and the smoke goes RED on a plugin the install never gets.

Why it matters: this is the same class of defect as round-1 nit 2 (a true conclusion resting on the wrong reason), and it survived because the round-1 reviewer supplied the wording verbatim. The stated invariant is also the one that is not guarded: grep -rn V1_EXCLUDED_FROM_DEFAULT test/ finds only per-plugin spot checks (test/plugins/hermes-manifest.test.js:67, test/plugins/vector-search-manifests.test.js:40-41), no disjointness assertion. A reader who takes the comment at face value would check the wrong property. Note the commit message for 2883a70 gets this right ("those two lists partition the whole bundled workspace" — partition implies disjoint); only the JSDoc drops the load-bearing half.

Exact fix — replace lines 582-584 with:

 * restating it: it agrees with the cut `ridersInDefaultSet` makes, because
 * the allowlist and the excluded set are disjoint, so nothing in `loaded` is
 * something that filter drops. A plugin in neither list is invisible to both
 * sides: it is not in `loaded`, and `loadPickerCatalog` reads only those two
 * buckets.

Direction of the failure this describes is fail-safe (false red, never false green), which is why it is a nit and not a correctness finding.

Round-1 findings, re-derived

  1. minor, single pass vs fixpoint — fixed. The head helper now carries while (grew) with present.add(manifest.name) inside the loop, matching ridersFor (src/core/cli/walkthrough.js:1074-1092). Re-derived both directions: with hermes set to compose_with: ["@hypaware/context-graph"] the smoke is green at 2883a70 and the shipped hyp init writes ..., @hypaware/ai-gateway-graph, @hypaware/context-graph, @hypaware/hermes; reinstating 8bcc18c's single-pass body (which I diffed out of that commit — it never called present.add) under the same manifest edit turns it red, dumping the config with hermes present in the write and missing from the golden. The fixer's "independently reproduced" claim is accurate. JSDoc line 569 now says "Run to a fixpoint, mirroring ridersFor", which is true, and lines 557-559 ("names plugins that are, transitively, already composed") now match the code rather than describing a rule the code did not implement.
  2. nit, "the same cut" — fixed, but the replacement has a new (smaller) problem. The false "same cut" claim is gone. The new wording's reason is wrong in a different way; see finding 1. The conclusion it supports is still true today: discoverBundledPlugins() returns 13 loaded, 9 excluded, unknownDirs: [], failed: 0, and V1_BUNDLED_PLUGIN_ALLOWLIST ∩ V1_EXCLUDED_FROM_DEFAULT = ∅ (computed, not eyeballed). So the fixer was right that the corrected wording no longer needs the counts to be true — but it does need disjointness, which it does not state.
  3. PR body — fixed. The stale sentence is gone. Body line 40 now reads "reads the bundled manifests and, run to a fixpoint mirroring ridersFor, returns those whose compose_with names are, transitively, all in the picked set (a rider whose compose_with names another rider still lands)", which is what the code does. Line 46's claim that the mechanism's unit home covers "the fixpoint" checks out (test/core/compose-picker-config.test.js:455-457, "a rider that rides another rider still composes"). Body line 48's "reproduces the default-activation boundary" is a claim about behaviour, not about why, so it is unaffected by finding 1. The rest of the body (attribution to 5b13456, the package_bin_boot/package_bin_boot is red on master: the --help banner changed and the smoke still pins the old copy #758 section, the not-implemented CI recommendation) still matches the tree, and the diff is still one file, so the CI section is correctly labelled as a recommendation.

Ordering, attacked

First the argument, traced independently. loadPickerCatalog (src/core/cli/walkthrough.js:2041-2050) builds buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) with no installed manifests, and the shipped hyp init path injects a catalog built the same way (src/core/cli/wizard/index.js:974), consumed at src/core/cli/wizard/pick.js:83-90. buildPluginCatalog inserts into composeWith in array order (src/core/plugin_catalog.js:45-62), a Map iterates in insertion order, ridersInDefaultSet rebuilds the map preserving relative order, and it removes exactly the excluded-bucket tail. So the implementation's iteration sequence is riders among loaded, in loaded order — precisely what the helper iterates. loadManifests (src/core/manifest.js:88-99) uses Promise.all and partitions in result order, so readdir order is preserved deterministically rather than by completion race. Both loops then have the same shape (guards reordered but all continue, present grown inside the pass). The orders match structurally, not by luck.

Cases constructed, each applied to the head worktree and reverted (git checkout -- .) after:

case construction result
baseline order none riders [ai-gateway-graph, context-graph]loaded array order is ai-gateway, ai-gateway-graph, claude, codex, context-graph, format-iceberg, format-jsonl, format-parquet, hermes, local-fs, openclaw, otel, s3
A1: second-order rider that only becomes composable in pass 2, sitting earlier in the array than its dependency ai-gateway-graph (idx 1) → ["@hypaware/hermes"], hermes (idx 8) → ["@hypaware/ai-gateway"] green; both sides produce [context-graph, hermes, ai-gateway-graph] — the pass-2 rider correctly lands last, not at its array position
A2: three-link chain crossing two pass boundaries, plus an excluded rider, plus a permanently unmet rider ai-gateway-graph[s3], s3 (idx 12)→[hermes], hermes (idx 8)→[context-graph], gascity (excluded)→[ai-gateway], format-jsonl[openclaw] (openclaw never picked) green; order [context-graph, hermes, s3, ai-gateway-graph], gascity filtered by ridersInDefaultSet and invisible to the helper, format-jsonl absent from both
A3: rider whose manifest directory order differs from the order it becomes composable covered by A1/A2 (idx 1 composes last, idx 12 composes third) green
A4: excluded rider positioned among non-excluded ones not constructible as a between case: buildPluginCatalog is fed [...loaded, ...excluded], so excluded entries are always a suffix, and ridersInDefaultSet removes exactly that suffix. Tested as A2's gascity leg green
A5: the one divergence I found a name in both V1_BUNDLED_PLUGIN_ALLOWLIST and V1_EXCLUDED_FROM_DEFAULT (hermes) with a satisfied compose_with red (false red). Fail-safe direction; requires an inconsistent two-list edit in bundled.js, not a manifest edit. This is the evidence for finding 1, not a separate defect

Then a randomized differential, since three hand cases prove little about a fixpoint: 8000 random compose_with graphs (1-6 riders, random insertion order, chains, cycles, self-references, unsatisfiable edges) fed simultaneously to the real composePickerConfig (i.e. ridersFor) and to a verbatim copy of the smoke's helper loop over a synthetic loaded array in the same order, comparing the rider tails with exact array equality. Zero divergences. Two seeds, one run with self-references excluded and one including them.

On the two discoverBundledPlugins() calls disagreeing: they cannot, on this path. Neither the picker path nor the helper accepts a workspaceDir override (no HYP_PLUGINS* env var reaches loadPickerCatalog/loadWizardCatalog; smokeEnv sets only HYP_HOME), both resolve defaultBundledWorkspaceDir() from src/core/runtime/bundled.js, and the smoke runs from the repo. The residual risk is a plugin directory appearing between the two calls inside a single smoke run, which nothing in the flow does.

Also checked, clean

  • The literal half of goldenPickerConfig, end to end. Ran the shipped CLI (node bin/hypaware.js init --yes --client claude --client codex --source claude --source codex --source otel --export local-parquet --retention-days 30 --dry-run --bin …) in a throwaway HOME/HYP_HOME and diffed the written hypaware-config.json against the golden: same six picked plugins in the same order (ai-gateway, otel, local-fs, format-parquet, claude, codex), the three upstreams in order, listen_host/listen_port 127.0.0.1/4318, the local sink (format-parquet writer, local-fs destination, <hypHome>/exports, */5 * * * *), and retention.default_days: 30. Matches composePickerConfig's own push order (walkthrough.js:995-1012), including riders appended after postExportPlugins, which is what the new inline comment at lines 643-644 claims.
  • Mutation re-runs after the fixpoint change (the three most load-bearing of round 1's ten): M1 dropped pick (postExportPlugins.slice(1)) → RED; M2 undeclared extra plugin pushed before the rider fold → RED; M5 reversed rider order (return added.reverse()) → RED. The fixpoint loop weakened none of them; M5 in particular confirms the derived tail is still order-sensitive rather than set-compared.
  • async propagation. goldenPickerConfig has exactly one call site (line 244), awaited, and composedRiders exactly one (line 645), awaited. No other file references either. npm run typecheck (tsc -p tsconfig.json --noEmit) clean.
  • Error handling. composedRiders has no try/catch. discoverBundledPlugins swallows only ENOENT (returns all-empty); any other readdir error propagates, so goldenPickerConfig rejects — and the await at line 244 sits inside the try opened at line 179 whose finally (548-551) restores process.env.HOME and closes the echo upstream, so the failure is loud and does not leak the server or the env mutation. The narrow symmetric-degradation case round 1 flagged (ENOENT → helper returns [] and loadPickerCatalog's catch returns empty maps) is unchanged by this round and remains unreachable from a repo checkout; it would also strip every picker descriptor, so the config would diverge on the literal half anyway.
  • The smoke's other assertions. Nothing downstream pins a plugin count in a way the wider config could break: wizard.pick.write_config asserts plugin_count >= 4, wizard.pick.start asserts sources_available === 8 (picker rows, which riders do not contribute — LLP 0213 #d1's whole point), wizard.pick.finish asserts the picks, and sections 3-6 (dry-run file preservation, OTLP + gateway round-trips, count(*) on logs and ai_gateway_messages, the span/log contract) are untouched by the rider tail. The updated header comment at lines 39-42 ("the written config is wider than the six plugins this smoke activates by injection") is accurate: activation is still the six injected workspace plugins.
  • M6 blind spot unchanged, not widened. A compose_with typo still moves both sides together and stays green here; it remains caught by test/core/compose-picker-config.test.js, which asserts the pair by name against the real catalog. The fixpoint change does not touch that boundary.
  • Conventions. No U+2014 anywhere in the changed file (and test/core/llp-ref-hygiene.test.js enforces that repo-wide on every npm test, so it is mechanically guarded, not just spot-checked); no trailing semicolons; no @typedef; no inline import('...') types; no new @import needed. The one new import is a runtime .js import matching the file's existing block. @ref LLP 0213#d1 [tests] resolves — the anchor exists at llp/0213-graph-plugin-always-active.decision.md:89 (### D1: both graph plugins are composed by default {#d1}) — and is attached (JSDoc closes at 589, async function composedRiders at 590, no blank line). The helper sits directly above its only caller, which is the file's idiom.
  • What I ran, all in the detached worktree after a fresh npm install: npm test → 4025 tests, 4024 pass, 1 skipped, 0 fail (15.9s), matching the PR body; npm run typecheck → clean; the full 13-flow battery → 12 green, package_bin_boot red for the known #758 tagline drift (package_bin_boot.js pins 'hyp - HypAware kernel CLI'), untouched by this PR; walkthrough_picker_to_first_query under 5 mutations, 5 ordering constructions, and 1 reinstated-old-helper reproduction; and an 8000-case randomized differential. Nothing was written to the branch, no commit, no push, no PR or comment edited. git status clean; my three scratch files (probe.sh, probe/, diff-riders.mjs) removed.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral triage - ship

Review budget exhausted with one residual finding. It is a preference. This PR can
merge.
Deferred to #761.

The finding is a JSDoc comment justifying a correct mechanism with the wrong reason:
it says reading only loaded agrees with ridersInDefaultSet because the two
lists cover the workspace, when the load-bearing property is that they are
disjoint.

Triage checked the thing that would have changed the answer rather than taking the
severity label: are the two lists actually disjoint today? Computed directly from
src/core/runtime/bundled.js, V1_BUNDLED_PLUGIN_ALLOWLIST (13 names) and
V1_EXCLUDED_FROM_DEFAULT (9 names) have an empty intersection. Had they not, this
would not have been a nit at all: the smoke would be red on master right now. It also
confirmed the reviewer's claim that no test guards the invariant, which is why the
follow-up carries a test and not just a wording fix.

The failure mechanism is real but fail-safe in both senses: it needs an inconsistent
two-list edit in bundled.js (never a manifest edit), and it produces a false red
on a release smoke rather than a false green. A comment that stops a release is not a
production defect.

Worth recording, because it is a process signal rather than a code one: this is the
second consecutive round where a true conclusion in this file rested on a wrong
stated reason, and round 2's wording came verbatim from round 1's reviewer. The
mechanism that normally catches this is the one that introduced it. That changes the
follow-up (pin the invariant with a test so the guard stops depending on prose
review) rather than the disposition.

Also checked, since master moved mid-review: PR #721 merged as 8c08185f and shares
zero files with this PR. The flow does exercise the query engine through a
JSON_VALUE filter, but #721 replaced the pushdown converter behind an unchanged
public surface; if that had broken, every data-asserting smoke on master would be red
independently of this PR.

Verified at head 2883a70 after a fresh install: npm test 4024 pass / 0 fail / 1
skipped, npm run typecheck clean, npm run smoke -- walkthrough_picker_to_first_query
ok, node --test test/core/compose-picker-config.test.js 0 fail.

@philcunliffe
philcunliffe marked this pull request as ready for review August 14, 2026 00:34
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 14, 2026
@philcunliffe
philcunliffe merged commit 576c8b9 into master Aug 14, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-750 branch August 14, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

walkthrough_picker_to_first_query is red on master, and no smoke runs in CI

1 participant