Skip to content

Install-experience overhaul: guided wizard with join-team and local pathways (LLP 0128)#375

Merged
philcunliffe merged 27 commits into
masterfrom
integration/install-experience-overhaul
Jul 22, 2026
Merged

Install-experience overhaul: guided wizard with join-team and local pathways (LLP 0128)#375
philcunliffe merged 27 commits into
masterfrom
integration/install-experience-overhaul

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Implements the install-experience overhaul from RFC LLP 0128 (design LLP 0135, plan LLP 0136), reworking hyp init's first-run walkthrough into a guided wizard with a top-level fork (Join a team / Local install) and a distinct configure phase for sources needing interactive setup.

What landed (13 tasks, all verified-merged --no-ff)

  • T1 kernel-types/manifest contract: PluginPickerContribution[], PickerDetectProbe union, dataset attribution_column, CommandRunContext.commands.run.
  • T2 buildPluginCatalog picker descriptors + detect.js migration off the hardcoded DETECTABLE_CLIENT_SOURCES.
  • T3 classifyClientProvenance (central/local/absent) + hyp status syncing/local-only split line (LLP 0132).
  • T4 ctx.commands.run(name, argv) in-process command-dispatch seam.
  • T5 source-scoped export withholding in readRowsSince via sourceWithholdResolver + attribution_column (privacy boundary, LLP 0132).
  • T6 descriptor-driven composePickerConfig; PICKER_SOURCES migrated onto plugin manifests.
  • T7 wizard fork phase + returning-gate amendment (LLP 0129).
  • T8 wizard join phase (login lane, LLP 0134) + locked-source computation.
  • T9 wizard pick phase (catalog-driven, locked-row rendering, LLP 0031).
  • T10 wizard configure phase, drop-on-failure (LLP 0131).
  • T11 wizard orchestration state machine (runInitWizard), init.js call-site switch, telemetry rename.
  • T12 @hypaware/claude-desktop plugin scaffold.
  • T13 claude-desktop install/verify implementations (LLP 0133): login, helper write, residue clear, managed-preferences plist via sudo, restart, two-tier verify.

Notes

Change-Set: install-experience-overhaul

neutral-loop and others added 27 commits July 22, 2026 19:19
…er validation

Add the published-contract shapes the install-experience overhaul builds on,
types only (T4/T5 wire the runtime):

- `PluginPickerContribution` (label, summary?, detect?, needs_setup?,
  configure_command?) and the `PickerDetectProbe` union (settings_file /
  app_bundle / path) in hypaware-plugin-kernel-types.d.ts.
- `contributes.picker: PluginPickerContribution[]`, an array sibling to
  `contributes.client`, so one plugin can contribute multiple picker rows.
- Optional `attribution_column` string on `contributes.datasets[]` for
  source-scoped export withholding (LLP 0132).
- Optional `commands: { run(name, argv): Promise<number> }` seam on
  CommandRunContext. Optional here because the dispatcher does not populate
  it yet; T4 wires the runtime and tightens it to always-present.

Teach the plugin manifest validator (validateManifest) to accept the picker
array and validate probe variants (exactly one of settings_file/app_bundle/
path, non-empty string), keeping unknown row fields (e.g. compose) opaque.

Task-Id: T1
runWizardFork(opts) in src/core/cli/wizard/fork.js implements the
init wizard's top-level pathway fork ("Join a team" / "Local install
and configuration" / quit, quit default on bare enter). evaluateReturningGate(opts)
amends LLP 0011's returning-install gate per LLP 0129: a managed
machine offers a scoped "adjust what this machine collects" entry
instead of dropping Reconfigure outright, a solo machine's Reconfigure
re-enters the full fork. The gate only reads the existing hyp status
summary and central-layer check (collectHypAwareStatus), so this phase
has no dependency on the picker-descriptor plumbing other wizard tasks
build on.

Task-Id: T7
Populate CommandRunContext.commands in the dispatcher as a thin
run(name, argv) wrapper over the module-private runCommandByName,
pulled off the same stdout/stderr/stdin/env/cwd/registry/kernel the
dispatch already holds, the same way skills/agents/backfills are
populated from the kernel. Exposes only run, never the mutable command
registry. Tighten CommandRunContext.commands from optional to required
now that the dispatcher always populates it (the shape T1 declared).

Test invokes one registered command through the seam and asserts the
exit code and stdout/stderr are byte-identical to running the same
command through the normal CLI path.

Task-Id: T4
…tion

Declares contributes.client (name claude-desktop, skill_dir/agent_dir
mirroring the embedded CLI's own .claude paths, and an attach_probe
pointed at the managed-preferences plist per LLP 0133's corrected local-
surface finding) and contributes.picker (label "Claude Desktop", the
app_bundle detect probe, needs_setup, configure_command "claude-desktop
install") on the existing claude-desktop plugin manifest, plus stub
"claude-desktop install"/"claude-desktop verify" commands in activate()
so the wizard's generic catalog reading and pick phase can pick this
plugin up with no further code change once T2/T9 land.

Task-Id: T12
Reads contributes.picker in the same pass buildPluginCatalog already
reads contributes.client, into a new first-manifest-wins
pickerDescriptors: Map<string, PickerDescriptor> keyed by each row's
name.

Adds a required name: string field to PluginPickerContribution
(kernel-types + manifest validator + design doc), since
contributes.picker is array-shaped specifically so one plugin manifest
can contribute more than one picker row (e.g. ai-gateway's
raw-anthropic/raw-openai), and the plugin's own top-level manifest
name can't disambiguate between them. Mirrors every other array-shaped
contributes.* entry (skills, commands, agents, datasets,
init_presets), which all key off a per-row name.

Replaces detectClientSources's hardcoded DETECTABLE_CLIENT_SOURCES
table in src/core/cli/detect.js with detectPickerSources(catalog,
env), switching on the PickerDetectProbe variant (settings_file reuses
resolveClientSettingsPath's parent-dir-exists check; app_bundle and
path stat the literal path, path honoring the same $FOO_HOME
env-override resolution). Still best-effort: a probe failure is "not
present," never thrown. Existing claude/codex detection behavior is
byte-identical, proven by the existing detect tests re-pointed at the
new function against a fixture catalog.

walkthrough.js's one call site is updated to a small
defaultPickerDetect wrapper that builds a catalog and delegates to
detectPickerSources; PICKER_SOURCES itself is untouched (T6's job).

Task-Id: T2
Add src/core/cli/wizard/configure.js: runConfigurePhase(picked, opts)
loops picked descriptors with needs_setup: true and a configure_command,
narrates each one at a time, runs it in process through T4's
ctx.commands.run seam, and applies the drop-on-failure rule - a non-zero
exit or a throw drops that source with a printed catch-up hint and the
phase continues with the rest.

Threads --print-commands onto the invoked command's own argv rather than
adding a second no-sudo escape hatch, and stays attended-only: a
non-interactive opts.picks path runs nothing.

Tests cover the success, drop-on-nonzero-exit, and drop-on-throw branches
plus the --print-commands passthrough and the attended-only guard, all
against a fake ctx.commands.run.

Task-Id: T10
Add `classifyClientProvenance(clientName, layered, catalog)` in new
`src/core/cli/wizard/provenance.js`, generalizing the central-vs-local
read `classifyInactiveState` does for one disabled-plugin case into a
`'central' | 'local' | 'absent'` three-way for any picker source id.
It resolves the id to its owning plugin via the catalog's picker/client
descriptors and checks membership in the central vs effective layers
(LLP 0031). Shared, correctness-critical plumbing: the pick phase's row
locking (T9), the export seam's withhold set (T5), and this task's
`hyp status` consumer all read it.

Land the `hyp status` consumer: the collector groups picked (configured)
clients by provenance into a new `clientSync` split, and the renderer
prints the "syncing: X - local-only: Y" line, so a local addition on a
managed machine is never a silent state (LLP 0132 #never-silent). Null
on a solo host, keeping the V1 surface unchanged.

Unit-tests cover the three-way classification directly and the status
split end to end.

Task-Id: T3
Add the real hyp claude-desktop install and hyp claude-desktop verify
command bodies (LLP 0133#one-surface), replacing the earlier stubs:

- install.js runs five idempotent steps: credential login chain (LLP
  0117, skipped in org_key mode, skipped if already signed in), helper
  write (delegates to the existing install-helper command, LLP 0116),
  dialog-residue check/backup/clear (Claude-3p profile directory under
  Application Support, unconditional on every run), managed-preferences
  plist write via an inline sudo prompt (LLP 0133#solo-sudo, only when
  the rendered plist differs from what is on disk), and a restart
  prompt (killall cfprefsd). Every step re-checks its own already-done
  state first, so a bailed sudo prompt converges on re-run without
  re-doing finished steps (LLP 0131#idempotent-rerun). Installation is
  refused up front, before any step runs, if the effective gateway
  listen is ephemeral (127.0.0.1:0, LLP 0114, LLP 0133#consequences).
  --print-commands prints the two privileged command sequences (plist
  write, restart) without running them.
- verify.js implements the two-tier verify (LLP 0131#verify-is-a-hint):
  the automatic half (plist present and up to date, residue cleared)
  drives the exit code; the in-app half (send a message, confirm
  capture) is printed as a hint only and never blocks.
- Extract HELPER_BASENAME/resolveHypBin/resolveHelperPath/resolveInputs
  out of index.js into a new inputs.js so install.js and verify.js can
  both depend on them without a circular import back into index.js.
- Export profile.js's shellQuote so install.js can render
  --print-commands output with the same quoting the credential wrapper
  script uses.

Covered by new test/plugins/claude-desktop-install.test.js and
test/plugins/claude-desktop-verify.test.js (19 tests): login-chain
branching by credential mode and prior sign-in state, ephemeral-listen
refusal with zero side effects, residue backup/clear and its
idempotent no-op re-run, plist skip-when-up-to-date vs sudo-write-when-
stale, drop-on-step-failure without stranding later steps,
--print-commands producing no spawn calls, and verify's automatic/
in-app split including that checkInstallState never mutates state.

npm run typecheck is clean. npm test: 2550 tests, 2542 pass, 1 skip,
7 pre-existing failures in test/core/leave-command.test.js unrelated
to this change (environmental: no network egress and no systemctl
binary in this sandbox, confirmed present on the integration branch
before this task's changes and unchanged after).

Task-Id: T13
…manifests

Replace walkthrough.js's wantsAnthropic/wantsCodex switch with a fold over
each picked descriptor's own `compose` data ({ plugin, requires_gateway,
gateway_upstream }), unioning requested gateway upstreams deduped by name
and including @hypaware/ai-gateway iff any picked descriptor requires the
gateway. Delete PICKER_SOURCES; its five entries are now contributes.picker
blocks (with compose) on @hypaware/claude, @hypaware/codex, @hypaware/otel,
and @hypaware/ai-gateway (raw-anthropic + raw-openai). PICKER_EXPORTS is
untouched.

Adds PluginPickerCompose/PluginPickerGatewayUpstream to the kernel-types
contract and the `compose` field to PickerDescriptor, read in
buildPluginCatalog. A documented display-order policy keeps the picker's
row order (clients first, then raw modes, then infra) stable and
deterministic. New compose-picker-config test pins the exact config output
byte-for-byte against the real bundled manifests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Task-Id: T6
Add `src/core/cli/wizard/join.js` with `runWizardJoin(opts)`, a thin
narration wrapper around the existing `runRemoteLogin` (@ref LLP 0134
#login-lane) - never a second enrollment mechanism. It runs the login
lane, waits (bounded) for the daemon to converge on the org config, and
computes `lockedSources` as the picker source ids the central layer owns
(T2's `pickerDescriptors`, T3's `classifyClientProvenance`). On timeout
or the no-org-config 404 steady state it narrates and returns an empty
lock set rather than blocking (@ref LLP 0129#join-before-picker).

- Export `waitForCentralConverge` from `remote_commands.js`, reusing the
  bounded reconcile-wait (`waitForClientAttach`) `runRemoteLogin` already
  performs internally instead of a second poll loop.
- Add `classifyLoginFailure`, mapping the login lane's D7 taxonomy
  (`no_membership` / `org_not_permitted` -> definitive, vs transient) to
  `'failed' | 'abandoned'`; the two definitive D7 phrases are lifted to
  exported constants so the classifier reuses the taxonomy rather than
  re-encoding it.
- Tests cover the converge, timeout, and login-failure branches, the
  locked-source computation against a fixture layered config, the D7
  classification split, and `waitForCentralConverge`'s ok/timeout verdict.

Task-Id: T8
Add `runWizardPick(opts)` in `src/core/cli/wizard/pick.js`, extracting the
pick/compose/guard/write shape from `runPickerWalkthrough` but sourcing rows
from `catalog.pickerDescriptors` (T2) instead of the retired `PICKER_SOURCES`
table. A row's checked state is `detected.has(id) || locked.includes(id)`; a
locked id renders `disabled: true` with the `· managed by your fleet` label
(LLP 0031 provenance vocabulary) and is filtered out of the returned sources
before composition, so a central-layer source is never re-composed into the
local layer (LLP 0129 join-before-picker). Composition itself reuses T6's
descriptor-driven `composePickerConfig`. Non-interactive callers set
`opts.picks` and skip prompting/detection, matching the existing
`interactive = !opts.picks` split.

Support the locked rendering by threading an optional `disabled` flag through
the TUI multiselect (types, reducer, render): a disabled row is context-only,
not toggleable by space or select-all, and renders dimmed. Export the shared
pick helpers from `walkthrough.js` for reuse rather than duplicating them.

Tests: pick-phase behavior (non-interactive compose, detection pre-check,
descriptor-sourced options, locked-row disable + filter, overwrite guard,
cancel) plus TUI reducer/render coverage for disabled rows.

Task-Id: T9
Extends readRowsSince (src/core/cache/storage.js) with a second optional
resolver, sourceWithholdResolver, threaded the same way usagePolicyResolver
already is: drop-but-advance, so a withheld row still moves the watermark
(LLP 0070#incremental continuation semantics).

createSourceWithholdResolver (src/core/cache/source-withhold.js) is a pure,
catalog-agnostic factory over plain data (withheld source ids + a
dataset -> attribution-column map). buildSourceWithholdResolver
(src/core/runtime/source_withhold.js) does the boot-time reduction from
classifyClientProvenance (T3) and the plugin catalog: the set of picker
source ids classified 'local' on a machine with a central layer. bootKernel
now builds the kernel runtime after the catalog and layered config are
resolved (previously built at the top of the function) so it can pass this
resolver through createKernelRuntime.

Per-row matching uses the new attribution_column manifest field (T1).
@hypaware/ai-gateway declares attribution_column: "client_name" on its
ai_gateway_messages dataset contribution. A dataset with no declared
attribution_column is never subject to source-scoped withholding, the
conservative default matching local-only's existing design.

Tests cover withhold-and-advance, the no-attribution-column passthrough
default, no-resolver passthrough, projection-can't-bypass, and composition
with the existing cwd-based local-only filter, all directly against
readRowsSince.

Task-Id: T5
…/T9' into HEAD

# Conflicts:
#	src/core/cli/wizard/types.d.ts
… claude-desktop row

Two manifest defects found in the install-experience overhaul (PR #375)
that shipped "green" because tests relied on hand-written fixtures that
diverged from the real bundled manifests:

- claude/codex picker rows omitted the `detect` probe. The retired
  `DETECTABLE_CLIENT_SOURCES` table detected these via `.claude/settings.json`
  and `.codex/config.toml`; without the manifest probe `detectPickerSources`
  never pre-checks them (LLP 0136 T2/T6 required byte-identical detection).
  Restore `detect: { settings_file: ... }` on both rows.

- The @hypaware/claude-desktop picker row omitted the required `name`.
  `discoverBundledPlugins` runs `validateManifest`, so the whole plugin
  landed in `bundled.failed` (a warning, not a boot error): every
  claude-desktop command (install/verify/profile/...) and the wizard's
  Claude Desktop picker row were silently unavailable. Add
  `name: "claude-desktop"` (the id the design, plan T12, and the tests
  already expect).

Regression guards: assert the real bundled claude/codex descriptors carry
their detect probes, and that no bundled manifest fails validation, so a
future manifest that a fixture-based test can't catch is caught here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review - install-experience overhaul (LLP 0128/0135/0136, T1-T13)

Verdict: findings (2 actionable defects, both fixed and pushed). Reviewed the full diff git diff origin/master...7fc6777. Both defects are the same class: a manifest that shipped while its unit tests passed against hand-written fixtures that had diverged from the real bundled manifest, so CI stayed green with a broken product.

Fixes pushed to integration/install-experience-overhaul as d49b19b.

Actionable findings (fixed)

F1 (High) - claude-desktop plugin fails to load entirely.
hypaware-core/plugins-workspace/claude-desktop/hypaware.plugin.json picker row omitted the required name field. discoverBundledPlugins runs validateManifest, which rejects it (contributes.picker entries require a name), so the whole @hypaware/claude-desktop plugin lands in bundled.failed - a warning, not a boot error. Result: every claude-desktop command (install, verify, profile, install-helper, status) AND the wizard's Claude Desktop picker row were silently unavailable. Verified at runtime: bundled.failed had 1 entry and pickerDescriptors had no claude-desktop before the fix; 0 failures and the row present after. This is the entire shipped surface of T12+T13, dead. config.test.js:376 asserts pickerDescriptors.get('claude-desktop') but against a fixture manifest that HAD the name, so it passed regardless.
Fix: add "name": "claude-desktop" (the id design LLP 0135, plan T12, and every test already expect).

F2 (Medium) - claude/codex picker rows lost autodetection (T2/T6 regression).
claude/codex picker rows in their manifests omitted the detect probe. The retired DETECTABLE_CLIENT_SOURCES table detected these via the .claude/settings.json / .codex/config.toml config homes; buildPluginCatalog only sets descriptor.detect when the manifest declares it, so detectPickerSources skipped them and they were never pre-checked in hyp init. T2/T6 explicitly required byte-identical detection. detect.test.js's claudeCodexCatalog() fixture supplies its own settings_file probes (its docstring even says the rows "will eventually contribute" them), so it could never catch a manifest shipped without one.
Fix: restore "detect": { "settings_file": ".claude/settings.json" } (claude) and ".codex/config.toml" (codex).

Regression guards added (test/core/compose-picker-config.test.js, both against the REAL bundled manifests, not fixtures): real claude/codex descriptors carry their settings_file probes; and discoverBundledPlugins().failed is empty (catches any future manifest a fixture-based test would miss).

High-risk area assessments

  1. T5 privacy boundary - SOUND. readRowsSince withholding (src/core/cache/storage.js) is correct on all three axes: (a) drop-but-advance - a withheld row does yield { after, dropped: true } so the sink watermark still moves past it (no stall, no re-export); (b) conservative passthrough - attributionColumnFor(dataset) returns undefined for a dataset with no attribution_column, so forceAttribution is false and shouldWithhold is never consulted (verified ai_gateway_messages declares attribution_column: client_name; nothing else does); (c) the withhold set is buildSourceWithholdResolver = picker ids classified 'local' by classifyClientProvenance, and it returns undefined when centralConfig is absent - a solo machine withholds nothing. Projection cannot bypass it (the attribution column is force-scanned then stripped, same guarantee as cwd). source-withhold-export-drop.test.js covers drop-but-advance, no-column passthrough, no-resolver passthrough, projection-omitting-the-column, and independent composition with the cwd filter. No leak-to-server or silent-drop path found.

  2. T13 sudo/plist idempotency - SOUND. Every step re-checks its own already-done state: login re-checks claude-account status before login (no repeat OAuth); helper delegates to the idempotent install-helper; residue clear backs up (cpSync) into the plugin state dir BEFORE rmSync, and re-runs with no residue are a plain skip; plist write compares on-disk bytes (plistUpToDate) before ever prompting for sudo. --print-commands gates only the privileged sudo/killall commands (verified: spawnCalls.length === 0), consistent with LLP 0133's framing of it as the "no-sudo escape hatch" (note: it still runs the non-privileged login/helper/residue steps by design - a mild surprise but backs up before clearing, so no data loss; not actionable). Plist path /Library/Managed Preferences/com.anthropic.claudefordesktop.plist matches LLP 0133#plist-surface. Only caveat is F1 above: the whole command was unreachable until fixed.

  3. T1 kernel-types contract - COHERENT. PluginPickerContribution/PluginPickerCompose/PickerDetectProbe/attribution_column/CommandRunContext.commands are internally consistent; the manifest validator accepts the shape and enforces name+label+single-variant detect while leaving compose opaque (documented). CommandRunContext.commands is required and the dispatcher always populates it (dispatch.js), typecheck green.

  4. T11 wizard orchestration - SOUND. join-before-pick is enforced structurally by data dependency: runWizardPick consumes opts.locked, computed by runWizardJoin from classifyClientProvenance(...) === 'central'. A central-locked row renders checked+disabled and is filtered out of composition (sources = rawSources.filter(id => !lockedSet.has(id))), so a central-locked source is never re-composed into the local layer (LLP 0031/0129). Configure phase is attended-only with drop-on-failure + catch-up hint. The phases are composable units not yet joined by a single hyp init orchestrator, which is consistent with the staged plan.

  5. T2/T6 migration - REGRESSED, now fixed (F2). Detection and compose were migrated onto manifest descriptors, but the two manifests shipped without their detect probes (F2). Compose is byte-identical (compose-picker-config.test.js pins the exact config against the real manifests and passes). After the fix, detection is byte-identical too.

Style compliance

No em dashes (U+2014) anywhere in the diff; no semicolons on added JS statements; @ref annotations spot-checked against real LLP sections (0132, 0133, 0129, 0130, 0031, 0117, 0116, 0131, 0058) and hold.

Tests

Full npm test after fixes: 2604 pass, 7 fail - all 7 are the known pre-existing leave family failures (sandbox), none in any area this PR or these fixes touch.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Round-2 neutral review - verdict: clean

Reviewed the round-1..head delta (7fc6777..d49b19b, 4 files / 31 insertions). Both round-1 defects are correctly and completely fixed; the delta introduces nothing new.

F1 - claude-desktop picker row dropped by manifest validation: FIXED (correct + complete).

  • hypaware-core/plugins-workspace/claude-desktop/hypaware.plugin.json now sets "name": "claude-desktop" on the picker row. validatePickerContributions requires a non-empty name per row (src/core/manifest.js:206); without it the whole manifest failed and discoverBundledPlugins routed it to .failed (a warning, not a boot error), silently dropping every command and picker row.
  • The row name matches the plugins client name (contributes.client.name = "claude-desktop"). Manifest is otherwise well-formed (single-variant app_bundle` detect probe, valid JSON).
  • Verified: discoverBundledPlugins().failed is now [].

F2 - claude/codex detection parity: FIXED (correct + complete).

  • claude row: detect: { settings_file: ".claude/settings.json" }; codex row: detect: { settings_file: ".codex/config.toml" }. These match the paths the retired DETECTABLE_CLIENT_SOURCES used (still mirrored in the first-party fallback descriptors, src/core/config/validate.js:642,653).
  • detectPickerSources -> probeIsPresent resolves settings_file via resolveClientSettingsPath (src/core/cli/detect.js:75), the same helper the retired table used; the in-code comment confirms "Unchanged behavior for claude/codex". Detection is byte-identical.

Regression guards (test/core/compose-picker-config.test.js, +28 lines): effective.

  • Both new tests assert against the REAL bundled manifests via discoverBundledPlugins() / realPickerDescriptors(), not hand-written fixtures.
  • Negative check: reverting the F1 name and the F2 claude detect in the worktree flips not ok 11 (detect probe) and not ok 12 (validation); restoring makes all 12 pass. The guards would catch either regression.
  • Cited LLPs (0130, 0133, 0136) all exist and are topically apt.

No new actionable findings. Full file node --test test/core/compose-picker-config.test.js: 12/12 pass.

Did not ready/merge/label.

@philcunliffe
philcunliffe marked this pull request as ready for review July 22, 2026 21:13
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 22, 2026
@philcunliffe
philcunliffe merged commit ccef8e3 into master Jul 22, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the integration/install-experience-overhaul branch July 22, 2026 21:23
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.

2 participants