Skip to content

Enforce plugin manifest and runtime command agreement - #849

Merged
philcunliffe merged 6 commits into
masterfrom
fix/issue-837
Aug 19, 2026
Merged

Enforce plugin manifest and runtime command agreement#849
philcunliffe merged 6 commits into
masterfrom
fix/issue-837

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What was wrong

Plugin command help has two independent sources and nothing compared them.
hyp --help renders before bootKernel and reads contributes.commands out
of hypaware.plugin.json (LLP 0009: booting to populate the registry would
import every entrypoint and bind listeners). Group and leaf help render after
boot and read the command registry activate() filled.

@hypaware/context-graph-enrich shipped the drift:

commandmanifest (hyp --help)registration (hyp enrich --help)
enrichContext-graph enrichment (subcommands: propose, curate, backfill, status)Context-graph enrichment
enrich statusShow enrichment watermarks and prospect/committed countsShow enrichment watermarks and counts

Separately, a plugin the config selects whose activate() fails is
advertised in top-level help and then reports hyp: unknown command when run:
LLP 0153/0154 only classify plugins the config does not select.

What changed

  • hyp plugin doctor gains command_help_drift (src/core/plugin_doctor/diagnose.js):
    a declared command whose manifest summary and registered summary differ
    (error), a declared command registered hidden (error), and a
    registerGroup description no declared command sits under (warn).
    Verb-projected commands need no extra machinery: a verb registers its CLI
    command into the same registry, so graph neighbors is compared like any
    other command.
  • A bundled contract test (test/plugins/bundled-command-manifest-agreement.test.js)
    runs that diff over every plugin in hypaware-core/plugins-workspace, so the
    bundled set is held to the check a plugin author gets. Reusing the doctor
    rather than writing a second harness is deliberate (LLP 0267 #d2).
  • hidden commands are exempt from contribution_undeclared. Manifest
    omission is how an internal command stays out of pre-boot help; the warning
    was nagging @hypaware/claude and @hypaware/codex about their hooks.
  • The doctor's dry run no longer runs sources.@hypaware/otel starts its
    OTLP source from activate(), so diagnosing a plugin bound 127.0.0.1:4318
    for real, and failed outright where the daemon already held it. The
    capability stub also answered Symbol.toPrimitive/valueOf/toString with
    itself, so a plugin logging a value read off a required capability blew up in
    string conversion and the doctor blamed the plugin.
  • The drift itself is fixed: @hypaware/context-graph-enrich now registers
    the manifest's wording (LLP 0009 says a bare group command's summary should
    name its headline subcommands).
  • Dispatch distinguishes selected-but-unavailable (src/core/cli/dispatch.js),
    a fourth miss state beside LLP 0154's three. With it, top-level help's
    epilogue ("run it anyway: hyp names the plugin that provides it") is true for
    this case too, so the wording did not need hedging.
  • CommandRegistry implementations gain listGroups(); RegisteredSnapshot
    gains commandDetails and commandGroups.

Regression tests

  • test/plugins/bundled-command-manifest-agreement.test.js fails on the
    pre-fix tree with two command_help_drift errors against
    context-graph-enrich, and passes after.
  • test/core/dispatch-inactive-plugin.test.js gains "dispatch miss on a
    selected plugin whose activate() threw reports unavailable, not unknown",
    which prints hyp: unknown command 'gascity' before the dispatch change.
  • test/plugins/plugin-doctor.test.js gains six unit tests: summary drift,
    verb-projected drift, the hidden exemption, declared-but-hidden, the group
    warning, and the no-source-start guarantee.

Local npm test (4287 pass, 0 fail) and npm run typecheck are green.

Design

New: llp/0267-manifest-and-runtime-command-agreement.decision.md. It extends
LLP 0009 (#d1) and LLP 0154 (#d5); forward-refs added to both.

Fixes#837

philcunliffe added a commit that referenced this pull request Aug 18, 2026
…#853)
`test/core/repo-scratch-hygiene.test.js` has been failing on `master` since it
merged: `x/npm-test.log` and `x/typecheck.log` are tracked, and the test's first
half asserts no `.log` is. Both came in on `adb448ab` (#785) via the `git add -A`
sweep that #786 wrote this test to catch; the files predate the test, so it was
red on arrival. Every branch cut since inherits it, currently blocking #833,
#849, #850 and #851 for a reason none of them caused.
The transcripts are deleted rather than the test relaxed, which is what its
message asks for. `.gitignore` needs nothing: `*.log` is already committed and
the rule test already passes, since an ignore rule cannot reach a path that is
already tracked. That asymmetry is the whole reason the file carries two tests.
Scope is exactly the two `.log` paths. The other eight files under `x/` are
untouched: the hygiene test forbids tracked transcripts and nothing else, and
sweeping up scratch it does not name would be a judgement this fix has no
authority to make.
Fixes#852
Co-authored-by: test <test@test.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

philcunliffe commented Aug 19, 2026

Copy link
Copy Markdown
ContributorAuthor

Review of fix/issue-837 @ e091ac5c

Verdict: findings (5 actionable, all fixed and pushed). The design is sound
and the LLP 0267 story holds up: the doctor is the right place for the check,
reusing it for the bundled contract test (D2) beats a second harness, and D3/D4
fix two real ways the doctor lied about a healthy plugin. Everything below is a
detail inside that shape, not an argument against it.

Baseline on the reviewed head

npm test (4513 pass / 0 fail / 1 skipped) and npm run typecheck were both
green on e091ac5c before any edit, so nothing here is pre-existing breakage.

Finding 1 (medium, fixed): the fourth miss state fires on an injected kernel

src/core/cli/dispatch.js:1174 decided "selected but this run did not get it"
by subtracting activePlugins from selection.selectedManifests. dispatch
only fills activePlugins inside the branch that boots the kernel
(src/core/cli/dispatch.js:247); when a caller passes opts.kernel the
variable keeps its [] initializer and no boot ever happens.

That is not a test-only path. src/core/cli/integration.js:110 (run(), the
public in-process escape hatch) forwards both registry and kernel, and
smoke flows use it. On any such call every plugin the effective config selects
looked like a failed activation, so a plain typo whose head token matched a
configured plugin answered with:

hyp: 'gascity' is provided by @hypaware/gascity, which your config selects but this run could not activate
repair: the plugin is configured but unavailable this run; run 'hyp status' for why, then re-run this command

for a plugin that is fine and that nothing tried to activate. hyp status then
shows nothing wrong, which is the same dead end LLP 0267 #d5 exists to remove.
Before this PR the same input correctly fell through to hyp: unknown command.

Fix: read bootKernel's own unavailablePlugins instead of re-deriving the
set. That list is exactly the four routes D5 enumerates
(src/core/runtime/boot.js:340-356: a throwing activate(), a dep-graph
elimination for an unsatisfied requires, a manifest that would not load, and a
config-enabled plugin the boot profile withheld), it is already in scope at the
call site as failedPlugins, and it is empty when dispatch did not boot, which
is the honest answer for an injected kernel. D5's behaviour is unchanged
wherever dispatch owns the boot.

Regression test in test/core/dispatch-inactive-plugin.test.js ("an injected
kernel is not read as a failed activation"), verified to fail on the pre-fix
dispatch.js and pass after.

Finding 2 (low-medium, fixed): a missing manifest summary reported as two wordings

PluginCommandManifest.summary is optional
(hypaware-plugin-kernel-types.d.ts:434) and the manifest validator accepts its
absence, but declaredCommands substitutes ''
(src/core/plugin_doctor/diagnose.js:401) and checkCommandHelp then emits, at
severity error:

command 'demo run' has two different summaries: the manifest says '' and activate() registers 'Run the demo'

The finding is right (top-level help would list the command with no
description), but the message sends the author hunting for a second wording that
does not exist, and nothing told them the optional field had become effectively
required.

Fix: the blank case now names itself ("has no summary in the manifest ...
top-level help lists the command with no description") and its repair hands back
the exact "summary": "..." line to paste. Severity is unchanged, so LLP 0267
#d1 still holds as written. docs/PLUGIN_AUTHORING.md now says the blank case
counts.

Finding 3 (low, fixed): the group warn only matched single-token prefixes

src/core/plugin_doctor/diagnose.js:358 compared a registered group name
against name.split(' ')[0] of each declared command, i.e. head tokens only.
Group prefixes are genuinely multi-token: resolveGroupHelp walks every leading
prefix (prefix = lead.slice(0, depth).join(' '),
src/core/cli/dispatch.js:592) and looks it up with registry.getGroup(prefix).
So a plugin declaring query cache list / query cache purge and calling
ctx.commands.registerGroup({ name: 'query cache' }) collected a permanent
command_help_drift warn telling it to drop a registerGroup call that is
correct and is being rendered for hyp query cache --help.

Fix: match the whole prefix (name === group.name || name.startsWith(group.name + ' ')).
Test added, verified failing before the fix.

Finding 4 (low, fixed): the bundled gate did not enforce what #d2 claims

LLP 0267 #d2 says "The test fails on an unreachable activate() as well, so a
plugin whose dry run never registers anything cannot pass by registering
nothing." The fatal filter in
test/plugins/bundled-command-manifest-agreement.test.js:92 listed only
entrypoint_import_failed, activate_missing, and manifest_invalid.
activate_threw was absent, and diagnose.js deliberately still runs the diff
after a throw (reachedActivation = dry.ok || dry.error?.kind === 'activate_threw'),
so a bundled plugin that throws before registering anything and declares no
commands passed this gate silently. Added activate_threw to the fatal set.

Finding 5 (low, fixed): duplicated head-token match

activateSeamCommandPlugins (src/core/cli/dispatch.js:944 on the reviewed
head) hand-rolled the same contributes.commands first-word scan this PR had
just extracted into declaresCommandHead. Folded into the helper; behaviour
identical.

Checked and deliberately left alone

  • inertSourceRegistry (D4) spreads the real registry and overrides only
    start, so stop/listStarted/stopAll still close over an internal
    started map that now stays empty. Harmless: the doctor only reads list(),
    the spread is safe (every method is a closure, no this), and a plugin that
    starts then stops a source in activate() still gets a working handle.
  • The capability stub's Symbol.toPrimitive / toString / valueOf guard
    is correct and violates no Proxy invariant on a function target.
    JSON.stringify still routes toJSON through the apply trap, but that
    terminates (a function serializes to undefined), so there is no second
    unterminating conversion left to fix.
  • snapshotRegistry's extra commandRegistry parameter is redundant
    (runtime.commands is that same object, src/core/runtime/activation.js:75)
    but it is correct and it documents why listGroups is not on the
    plugin-facing CommandRegistry. Left as written.
  • Partial-activation noise:checkCommandHelp runs when the dry run reached
    activation, activate_threw included, so a plugin that throws midway can emit
    a group warn about subcommands that never registered. The activate_threw
    error sits next to it in the same report, so the noise is bounded and
    self-explaining.
  • Manifest/registration agreement across the bundled set was verified by
    running the new contract test: @hypaware/ai-gateway's three session *
    summaries match src/index.js:66-95 exactly, and
    @hypaware/context-graph-enrich's two corrected summaries match.
  • @ref anchors in the new code and in LLP 0267 all resolve
    (0009#layered-help, 0009#top-level-help-lists-plugin-commands-without-booting,
    0034#verbs, 0214#d2, 0005#declarative, 0267#d1-#d5). No em dashes, no
    semicolons, no @typedef, no inline import('...') types in the diff.

Checks after the fixes

  • npm test: 4516 pass, 0 fail, 1 skipped.
  • npm run typecheck: clean.
  • npm run smoke -- cli_bundled_plugins_activated,
    walkthrough_picker_to_first_query, status_diagnostics: all ok (the
    hermetic smokes that exercise boot selection and dispatch).

Pushed as 930ad9ab and 38651e97 on fix/issue-837. Note that this review
record is keyed to the reviewed head e091ac5c, not to the post-fix head.

Process note

dual-review is unavailable on this host, so code-review was the review. It
ran at high effort and independently reached findings 1 through 4; finding 5 and
the verification above come from a direct read of the full diff and its call
sites.

philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
0266 is also claimed by fix/issue-836 (PR #850) and
update/icebird-squirreling-native-batches (PR #866); 0267 is also
claimed by fix/issue-837 (PR #849). Both of those PRs are older, so
this branch yields the numbers. 0276 and 0277 are free across master
and every open branch.
Mechanical renumber only: no content change (LLP 0156).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
…ff two false reports
Three defects found reviewing #849.
- `dryRunActivate` left `ctx.env` defaulting to `process.env`, so a plugin
that reads `HYP_HOME` during `activate()` was pointed at the caller's real
install. `@hypaware/local-fs` mkdirs `<HYP_HOME>/exports` from `activate()`,
so merely diagnosing it wrote into the home directory the function's own
contract promises not to touch, and the new bundled agreement test did it
for the whole workspace on every `npm test`. `HYP_HOME` now points at the
throwaway root the rest of the dry run already uses.
- The inert source registry returned a `StartedSource` the underlying registry
never recorded, so a plugin that starts one of its own sources from
`activate()` and then reloads it got `source 'x' is not started` and the
doctor reported `activate_threw` against a plugin that works. The no-op is
now swapped in at `register()` time and routed through the real lifecycle,
which keeps the bookkeeping intact and still runs nothing. A malformed
contribution passes through untouched so `register()` still rejects it.
- The `registerGroup` warning contradicted LLP 0267 #d3: a group whose
registered commands are all hidden is correctly absent from the manifest,
yet was warned as describing a group nothing lists, and the bundled gate
counts warnings as failures. Such a group is now exempt; a group with a
visible command under it still warns.
Also drops the "its manifest would not load" route from the
`findInactivePluginForCommand` doc: boot names an unloadable plugin by its
rootDir, not its plugin name, and an unreadable manifest declares no command
to match against, so that route is not (and cannot be) served here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round: 38651e97

Verdict: sound, with three real defects (now fixed and pushed as 5c6bd00c).

The design holds up. Reusing diagnosePlugin for the bundled gate rather than a
second harness (D2) is the right call, the selected-unavailable fourth miss
state is correctly checked before the not-selected pool, and the multi-word
group-prefix match is right (resolveGroupHelp walks every leading prefix, so
comparing head tokens only would have false-warned query cache).
npm test (4519 pass / 0 fail), npm run typecheck, and npm run build:types
are green on the reviewed head and on the fix commit.

Findings

1. src/core/plugin_doctor/dry_run.js:102 - medium. FIXED.
createActivationContext({ runtime, plugin, paths, config: {} }) passed no
env, so ctx.env fell back to process.env
(src/core/runtime/activation.js:136). @hypaware/local-fs's activate() then
runs fs.mkdir(resolveExportsBaseDir({ env: ctx.env }), { recursive: true })
(hypaware-core/plugins-workspace/local-fs/src/index.js:47-48) against the
real$HYP_HOME, contradicting the function's own contract two lines up
("roots all plugin paths in a fresh mkdtemp ... so no state leaks into
<HYP_HOME>").

Reproduced: HOME=<empty dir> node --test test/plugins/bundled-command-manifest-agreement.test.js created
<HOME>/.hyp/exports. Pre-existing in hyp plugin doctor, but this PR is what
makes it fire over the whole bundled workspace on every npm test.

Fixed by passing env: { ...process.env, HYP_HOME: tmpRoot }. The rest of the
environment is still passed through - the dry run is a diagnostic, not a
sandbox, and the file already says so. Verified: the bundled test now leaves an
empty HOME untouched.

2. src/core/plugin_doctor/dry_run.js:189 (was) - low. FIXED.
inertSourceRegistry().start() returned a handle the underlying registry never
recorded in its started map (src/core/registry/sources.js:25). A plugin that
starts one of its own sources during activate() and then calls
ctx.sources.reload(name, ctx) throws source 'x' is not started
(sources.js:135), and status(name) / started(name) answer undefined. The
doctor would report activate_threw against a plugin that is fine under the
real kernel - the exact false-blame class D4 exists to remove.

Fixed by neutering at register() time instead: the contribution is registered
with a no-op start, so the whole real lifecycle (span, started map,
sourcesStarted counter, stop/reload/status) stays intact and still runs
none of the plugin's code. A malformed contribution is passed through untouched
so the real register() still rejects it - a source missing start() is a
finding about the plugin, not something to paper over.

3. src/core/plugin_doctor/diagnose.js:370 - low. FIXED.
The group warning contradicted D3. A plugin that registers a group whose
subcommands are all hidden (correctly omitted from the manifest, per D3) got
command_help_drift warn "the manifest declares no command under it". Worse,
the bundled gate asserts deepEqual(commandFindings(...), []), which includes
warns, so adding such a group to any bundled plugin would break npm test with
no way to satisfy both rules at once: declaring the commands is exactly what D3
forbids.

Fixed with registersOnlyHidden(): a group whose registered commands are all
hidden is exempt. A group with a visible command under it, or with nothing
registered under it at all, still warns - both covered by new tests.

4. src/core/cli/dispatch.js:1138 (doc) - low. DOC CORRECTED.
The JSDoc listed "its manifest would not load" as one of the four routes
selected-unavailable covers, but it is unreachable: boot.unavailablePlugins
records unloadable plugins as rootDir paths
(src/core/runtime/boot.js:236-239), while the loop matches names in
selection.selectedManifests, which by construction only holds plugins whose
manifest loaded. And an unreadable manifest declares no commands to match
token against. A config-named plugin with a corrupt hypaware.plugin.json
still prints hyp: unknown command.

The code is right; the comment overclaimed. Corrected to say the route is
deliberately not served here and why. LLP 0267 #d5 is Accepted and its sentence
is a statement about the old behavior of all four routes, so it is left as
written.

Considered and not actioned

  • diagnose.js:335, blank manifest summary is error.summary is
    optional on PluginCommandManifest
    (hypaware-plugin-kernel-types.d.ts:434), so every third-party plugin that
    legally omitted it flips hyp plugin doctor from exit 0 to exit 1 with no
    code change. That is a real consequence, but LLP 0267 #d1 settles the
    severity and #rejected explicitly turns down "make the drift check a
    warning". Not a thing to reverse in a review. Worth a follow-up only if the
    intent is to make summary required in the manifest type so the error
    matches the declared contract. Related: a summary computed at activate time
    (summary: \Export to ${ctx.config.dest}`) can never match, since the dry run passes config: {}` - also a design consequence, not a bug in this PR.
  • src/core/registry/commands.d.ts:1.d.ts specifier. Checked against
    the repo: 17 of 18 src/**/*.d.ts files import
    hypaware-plugin-kernel-types.d.ts with the .d.ts extension, including
    every other line of this same file. The new line matches its file. CLAUDE.md's
    root-anchored-.js rule is about JSDoc @import in .js sources. No change.

Not caused by this PR

npm test still leaves ~/.hyp/exports behind, from
test/core/command-dispatch.test.js and test/core/boot-installed.test.js.
Confirmed present on origin/master, so it is pre-existing and out of scope
here - but the same class of leak, and worth its own issue.

Regression tests added

test/plugins/plugin-doctor.test.js gains four, all verified red on 38651e97
and green after:

  • a dry run points HYP_HOME at its throwaway root (finding 1)
  • a source started during a dry run can be reloaded and inspected (finding 2)
  • a group whose registered commands are all hidden is not warned about (finding 3)
  • a group with a visible command under it is still warned about (guards the
    finding-3 exemption from swallowing the case the warning is for)

npm test 4519 pass / 0 fail, npm run typecheck and npm run build:types
clean on 5c6bd00c.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage at head 5c6bd00c: every residual review finding is non-blocking, so this PR can merge safely. All actionable findings from both review rounds are fixed and verified at this head (targeted test files pass: 57/57). The two deferred, non-blocking items (doctor errors on a legally-optional blank manifest summary, and a pre-existing ~/.hyp/exports test leak) are tracked in follow-up issue #905.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 19, 2026
bgmcmullen added a commit that referenced this pull request Aug 19, 2026
…onfig is not a reconfigure (#874)
* LLP 0266: a hidden picker row stays off the sync gate too
On every enrolled machine the sync gate led with the two hidden
raw-proxy rows (raw-anthropic / raw-openai) wearing the fleet label,
because @hypaware/ai-gateway sits in the central layer so they classify
locked - while the picker had deliberately never offered them. The lane's
locked descriptors now go through the same visiblePickerDescriptors
filter the pick lane uses, at the screen, never at the locked set (which
would re-compose the org gateway into the local layer, LLP 0129).
The no-candidates short-circuit splits accordingly: with no visible org
row to name, it says nothing syncs instead of naming the fleet as owner
of an empty list.
Extends LLP 0202 and supersedes its "sync/opt-out menu is unchanged"
consequence line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* LLP 0267: an answer-less config does not make a reconfigure
hyp remote add before the first hyp init writes a config holding only
query.remotes. The pick phase classified any readable config file as a
reconfigure, so that run seeded from an empty read-back instead of from
detection: every box arrived unchecked, no defaults gate rendered, and
the export default quietly flipped to keep-local.
The classification now keys on whether the config records a pick answer,
discriminated by the plugins key: the composer always writes a plugins
array, the side-channel writers never do. An answer-less config seeds
like no config at all - detection pre-checks, gates render, export takes
the first-run local-parquet default - while its own keys still carry
through the composition fold. plugins: [] stays a reconfigure: an
emptied install must not be re-consented from detection.
Extends LLP 0183.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review round: hidden rows off the sync gate's candidate list too, and the no-candidates line stops claiming nothing syncs
Two defects in the LLP 0266 half of this PR, both on the sync gate.
The locked list went through `visiblePickerDescriptors`; the candidate
list did not. A carried hidden row (LLP 0202 #carry-through) reaches
`picked.descriptors` whenever that row is not locked - a team join whose
org config has not converged, or a machine whose central layer does not
declare `@hypaware/ai-gateway` - and the gate then rendered it as an
editable checkbox for a row the picker deliberately never offered, where
unchecking it writes a `local-only` entry for a source the user never saw.
Both row lists now take the same filter, which is what makes LLP 0266's
"absent from every wizard screen" true rather than half true.
The new no-candidates line then said "nothing syncs to your server" on
exactly the machine class LLP 0266 targets: an enrolled machine whose
locked set is entirely the hidden `raw-*` pair filtered out of the
display. Those rows are still locked, still composed by the org's central
layer, and under LLP 0188 #locked they always sync and can never be opted
out - so the sentence traded LLP 0202's over-disclosure for an
affirmatively false claim about what leaves the machine. The branch now
splits three ways on `lockedHidden`, a count the lane gets so it can tell
the truth about withheld rows without being able to name them.
LLP 0266 §sync-gate, §no-candidates, and §consequences updated to match.
* Renumber LLP 0266/0267 to 0276/0277 to clear number collisions
0266 is also claimed by fix/issue-836 (PR #850) and
update/icebird-squirreling-native-batches (PR #866); 0267 is also
claimed by fix/issue-837 (PR #849). Both of those PRs are older, so
this branch yields the numbers. 0276 and 0277 are free across master
and every open branch.
Mechanical renumber only: no content change (LLP 0156).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Sync gate must not claim nothing syncs while a hidden picked row stands
The LLP 0276 filter takes hidden rows off both sync-lane lists, but only
the locked list reported how many it removed. A carried hidden row (LLP
0202 #carry-through) that is not locked reaches picked.descriptors, is
composed into the local layer, and syncs unless an opt-out entry says
otherwise - and when it is the only pick, the filter empties `candidates`
with `locked` empty and `lockedHidden` 0, so the lane took the strongest
of its no-question sentences and told the user "nothing syncs to your
server" while capture was in fact leaving the machine. That is the
affirmatively false claim LLP 0276 #no-candidates ruled out for the
locked case and did not carry to the candidate case.
`runInitWizard` now passes `candidatesHidden` alongside `lockedHidden`,
one count per filtered list, and the no-question branch gains a fourth
line: with no locked row but a hidden pick standing, it states that
capture already set up on this machine still syncs, naming neither the
row nor the fleet (which does not own it).
LLP 0276 #no-candidates extended with the case and the sentence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: neutral <neutral@hyperparam.app>
Co-authored-by: test <test@example.com>
testand others added 6 commits August 19, 2026 12:48
Plugin command help has two sources that nothing compared: `hyp --help`
renders before boot and reads `contributes.commands` out of the manifest,
while group and leaf help read the registry `activate()` filled.
`@hypaware/context-graph-enrich` shipped two of its commands with different
summaries at the two levels.
- `hyp plugin doctor` gains `command_help_drift`: a declared command whose
manifest summary and registered summary differ, a declared command
registered `hidden`, or a group description no declared command sits under.
Verb-projected commands are covered for free, since a verb registers its
CLI command into the same registry.
- A new bundled contract test runs that diff over every plugin in the
workspace, so the bundled set is held to the check a plugin author runs.
- A `hidden` command no longer trips `contribution_undeclared`: omitting it
from the manifest is how it stays out of pre-boot help.
- The doctor's dry run stops running sources (`@hypaware/otel` bound a real
port from `activate()`) and stops handing back a capability stub that
cannot be converted to a string.
- The dispatch miss path distinguishes a plugin the config selects but the
boot did not get, which previously fell through to "unknown command".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LLP 0262 (#818) updated hyp session ignore to post to every local
recorder (claude listener + gateway proxy), not just the gateway, and
updated the activate() registration summary accordingly, but left the
hypaware.plugin.json manifest summary describing the old gateway-only
behavior. The new manifest/activate() agreement test caught the drift;
this brings the manifest summary in line with the runtime text.
… dispatch miss
The fourth miss state (LLP 0267 #d5) derived "selected but this run did
not get it" by subtracting `activePlugins` from the config-selected
manifests. `dispatch` only fills `activePlugins` when it boots the
kernel itself; a caller that injects one (`opts.kernel`, forwarded by
the integration API's `run()`) leaves it empty, so every config-selected
plugin looked like a failed activation and a plain typo answered with
"your config selects but this run could not activate".
`bootKernel` already publishes that set as `unavailablePlugins`: the
same four routes D5 names, and empty when dispatch did not boot. Read it
instead of re-deriving it, and cover the injected-kernel case with a
test. Also fold the seam's duplicated head-token match into
`declaresCommandHead`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the bundled gate
Three follow-ups on the new command_help_drift check.
- `summary` is optional on a manifest command entry, so an entry without
one is the shape authors most often land on. It is still drift (top
level help lists the command with no description), but reporting it as
"two different summaries: the manifest says ''" sends the author
looking for a second wording that does not exist. Name the blank and
hand back the exact line to paste.
- A group prefix is not always one token: `resolveGroupHelp` walks every
leading prefix, so `ctx.commands.registerGroup({ name: 'query cache' })`
is correct and renders for `hyp query cache --help`. Comparing only the
head token of each declared command warned about it forever. Match the
whole prefix.
- The bundled contract test treated `activate_threw` as non-fatal, so a
bundled plugin that throws before registering anything and declares no
commands passed the gate vacuously - exactly what LLP 0267 #d2 says it
must not do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ff two false reports
Three defects found reviewing #849.
- `dryRunActivate` left `ctx.env` defaulting to `process.env`, so a plugin
that reads `HYP_HOME` during `activate()` was pointed at the caller's real
install. `@hypaware/local-fs` mkdirs `<HYP_HOME>/exports` from `activate()`,
so merely diagnosing it wrote into the home directory the function's own
contract promises not to touch, and the new bundled agreement test did it
for the whole workspace on every `npm test`. `HYP_HOME` now points at the
throwaway root the rest of the dry run already uses.
- The inert source registry returned a `StartedSource` the underlying registry
never recorded, so a plugin that starts one of its own sources from
`activate()` and then reloads it got `source 'x' is not started` and the
doctor reported `activate_threw` against a plugin that works. The no-op is
now swapped in at `register()` time and routed through the real lifecycle,
which keeps the bookkeeping intact and still runs nothing. A malformed
contribution passes through untouched so `register()` still rejects it.
- The `registerGroup` warning contradicted LLP 0267 #d3: a group whose
registered commands are all hidden is correctly absent from the manifest,
yet was warned as describing a group nothing lists, and the bundled gate
counts warnings as failures. Such a group is now exempt; a group with a
visible command under it still warns.
Also drops the "its manifest would not load" route from the
`findInactivePluginForCommand` doc: boot names an unloadable plugin by its
rootDir, not its plugin name, and an unreadable manifest declares no command
to match against, so that route is not (and cannot be) served here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe
philcunliffe merged commit 0440c6a into masterAug 19, 2026
10 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-837 branch August 19, 2026 19:55
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral 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.

Enforce plugin manifest and runtime command agreement

1 participant

@philcunliffe