Skip to content

Client assets: one materializer, one install command (LLP 0138/0107) - #399

Merged
platypii merged 7 commits into
masterfrom
client-assets-one-install
Jul 27, 2026
Merged

Client assets: one materializer, one install command (LLP 0138/0107)#399
platypii merged 7 commits into
masterfrom
client-assets-one-install

Conversation

@platypii

@platypii platypii commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Skills and subagents are two shapes of one thing: a plugin-contributed file tree copied into a client's config directory. They differ in the copy (a directory tree vs a single .md) and in which manifest key names the destination. Nothing else. They shipped as parallel universes anyway: two registries, two commands, and the same copy loop written four times (both CLI commands plus two more inline in the wizard finale). The four had already drifted - the CLI tolerated a failed copy and warned, the finale threw; the CLI warned about a client with no directory for the asset kind, the finale skipped silently.

Along the way this lands LLP 0107, which was accepted 2026-07-13 and never implemented.

One materializer

New src/core/runtime/client_assets.js. materializeClientAssets() owns client targeting, containment, idempotent replace, tolerance of a bad contribution, and what gets reported; only the copy itself branches on kind. removeClientAssets() is the reversal half. Every caller routes through it: hyp skills install, the wizard finale, manual attach, and the reconciler's attach action.

One command

hyp skills install installs both kinds. hyp agents install is removed, not aliased - a hidden second spelling preserves the confusion it was introduced to fix. The registries stay two (plugins register two shapes); only the user-facing surface unifies.

Attach installs, as LLP 0107 said

runAttach dispatched to the adapter's attach() and never touched skills; the reconciler's attach action had no notion of them. The only path that materialized anything was the wizard finale. So a machine enrolled by hyp remote login, or a client wired by a hand-run hyp attach claude, got capture and no helper skills - including the hypaware-privacy skill the LLP 0100 review flow depends on.

Both attach paths now materialize. hyp skills install remains the manual path for re-copying after a local edit, without re-attaching.

Reversal

Per LLP 0107 §reversal: an org-driven attach records the destination paths it wrote as installed_assets on its action marker, and reverse() removes exactly those - so a user's own hyp skills install copies, which record no marker, survive a leave. What to remove cannot be re-derived from the live registries (they describe what the plugin set contributes now, not what that attach copied), so ActionHandler.reverse() now receives the marker it is about to drop. Backfill is unaffected; attach is the only implementor.

A copy failure never fails the attach: the settings write applied, and a failed marker would re-attach on every pass over a problem re-attaching cannot fix.

Incidental improvements

  • A contribution naming the literal client all (legal per PluginSkillClient) now expands to every targeted client, instead of warning about a client named "all".
  • A client with no directory for an asset kind (Codex has skills but no subagents) is a silent skip rather than a warning no one can act on.

Notes for review

  • ActionContext / ReconcileInput carry the skill and agent registries, threaded by the daemon from boot.runtime. Absent on a CLI boot, so the install half of attach is inert by construction, like the rest of the client seam.
  • The finale keeps emitting one skills.install span covering both kinds (the release smoke battery asserts it); agents.install is gone.
  • A pre-0138 attach marker has no installed_assets, so a leave that reverses it removes nothing - same outcome as a manual install. Self-healing: the next attach records the field.
  • hyp attach on a daemon-managed install that is already attached at the live port still short-circuits with "nothing to do" and installs nothing. The reconciler covers that machine, so it is not a gap, but say the word if the manual command should always ensure assets.

Checks

  • npm run typecheck clean
  • npm test: 2650 pass, 0 fail
  • smokes: walkthrough_picker_to_first_query, client_attach_idempotent, package_bin_boot, cli_bundled_plugins_activated, status_diagnostics, daemon_foreground_start_stop all green

Design rationale: LLP 0138, with an Extended-by: forward-ref added to LLP 0107.

@platypii platypii added the neutral:adopt Foreign PR adopted into neutral's reconcile scope label Jul 27, 2026
Skills and subagents are two shapes of one thing: a plugin-contributed
file tree copied into a client's config directory. They differed only in
the copy (directory vs single .md) and in which manifest key names the
destination, but shipped as parallel universes: two registries, two
commands, and the same copy loop written four times (both CLI commands
plus two more inline in the wizard finale). The four had already drifted
- the CLI tolerated a failed copy and warned, the finale threw.

Collapse them into `materializeClientAssets`
(src/core/runtime/client_assets.js), which owns client targeting,
containment, idempotent replace, tolerance, and reporting; only the copy
branches on kind. `hyp skills install` now installs both kinds, and
`hyp agents install` is removed rather than aliased - a hidden second
spelling would preserve the confusion it was introduced to fix.

This also lands LLP 0107, which was accepted 2026-07-13 and never
implemented: `runAttach` dispatched to the adapter and never touched
skills, and the reconciler's attach action had no notion of them, so the
only path that materialized anything was the wizard finale. A machine
enrolled by `hyp remote login`, or a client wired by a hand-run
`hyp attach`, got capture and no helper skills - including the
hypaware-privacy skill the LLP 0100 review flow depends on. Manual attach
and the reconciler's attach now both materialize.

Reversal follows LLP 0107 #reversal: an org-driven attach records the
destination paths it wrote as `installed_assets` on its action marker and
`reverse()` removes exactly those, so a user's own `hyp skills install`
copies (which record no marker) survive a leave. What to remove cannot be
re-derived from the live registries - they describe what the plugin set
contributes now, not what that attach copied - so `ActionHandler.reverse()`
receives the marker it is about to drop. A copy failure never fails the
attach: the settings write applied, and a `failed` marker would re-attach
every pass over a problem re-attaching cannot fix.

Two behaviors fall out of the shared routine: a contribution naming the
literal client `all` expands to every targeted client instead of warning
about a client named "all", and a client with no directory for an asset
kind (Codex has skills but no subagents) is a silent skip rather than a
warning no one can act on.

The finale keeps one `skills.install` span covering both kinds, which the
release smoke battery asserts on; `agents.install` is gone.
@platypii
platypii force-pushed the client-assets-one-install branch from 8efd687 to 8170ec7 Compare July 27, 2026 04:35
…dashes

Three findings from the review of this PR.

1. `hyp leave` left org-installed client assets on disk forever. Step 3
   reversed each org-driven attach through `detachClientViaCore`, which
   clears the action marker - and the marker's `installed_assets` was the
   only record of what the attach had copied. The assets outlived the
   leave with nothing left on disk naming them, which is exactly what
   LLP 0107 §reversal says must not happen ("hyp leave removes the skills
   they installed, exactly as it reverses settings edits").

   `runLeave` now reads `installed_assets` off the marker before the
   detach and removes those dests. Removal stays marker-driven, so a
   manual `hyp skills install` copy (which records no marker) survives.
   `detachClientViaCore` gains a `clearMarker` option, default true: leave
   passes false when removal failed, so the undo record survives for a
   re-run instead of being destroyed alongside the files it named.
   `readInstalledAssets` is exported from action_attach.js rather than
   copied, so the reconciler and the CLI cannot disagree about the field.

2. No containment re-check on removal. `removeClientAssets` recursively
   force-rm'd every string the marker held, validated only as "non-empty
   string". `client-actions.json` is a plain file on disk: one holding
   "/" or a home directory was a recursive delete. The write side already
   re-checks containment as defense in depth; the delete side has the
   weaker input of the two and now does too. `removeClientAssets` takes
   the allowed base dirs (`clientAssetBaseDirs`, the descriptor's own
   skill_dir/agent_dir under HOME) and reports an escaping dest as failed
   rather than removing it - which also keeps the marker for inspection.

3. Two em dashes this PR introduced, against the repo rule.

Tests: leave removes marker-recorded assets while an unmarked manual copy
survives; reverse() refuses a marker path outside the client's asset dirs
(escaping path, the skills dir itself, and "/") and leaves all three alone.

LLP 0138 #marker-undo gains the generalized rule the code now follows:
read the assets before dropping the marker, whoever drops it, and
re-check containment because the field is persisted JSON.

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

Copy link
Copy Markdown
Contributor

neutral review round 1: findings (3 fixed and pushed, 2 need your call)

Reviewed 8efd687 (force-pushed to 8170ec7 mid-review; the two are content-identical, empty git diff). Fixes pushed as 4752921. CI is green at that head, verified against the commit's own check-runs.

Fixed and pushed

1. hyp leave never removed org-installed assets, and destroyed the undo record. (major)
src/core/commands/central.js:446 reversed attaches through detachClientViaCore, not the attach handler's reverse(). detachClientViaCore then called clearClientActionMarker (src/core/commands/clients.js:422), dropping the marker and its installed_assets with it. Net effect: after a leave the copied skills and subagents sat on disk with no record of where they came from and no command that would ever remove them. removeClientAssets had exactly one caller in the tree, the reconciler-only reverse().

This is precisely what LLP 0107 §reversal specifies ("hyp leave removes the skills they installed, exactly as it reverses settings edits") and what LLP 0138 #marker-undo says the marker exists to make implementable, so it was a silent divergence from the doc this PR lands. test/core/leave-command.test.js had no asset coverage, which is why CI was green over it.

Fix: runLeave reads installed_assets off the marker before the detach clears it, removes those dests via removeClientAssets, and passes the new clearMarker: assetsRemoved option so a failed removal keeps the marker (and therefore stays retryable) instead of destroying the only record. readInstalledAssets is now exported from action_attach.js so leave and the reconciler share one reader of the persisted field rather than two copies of the filter. hyp detach is unchanged (clearMarker defaults true). A manual hyp skills install copy records no marker and still survives a leave.

2. Asset removal had no containment re-check. (minor)
removeClientAssets ran fs.rm(dest, {recursive: true, force: true}) on every string out of the marker, and readInstalledAssets validated only "non-empty string". The write side already re-checks isWithinDir as defense in depth; the delete side trusted the marker completely. Since client-actions.json is persisted JSON driving a recursive delete, a corrupted or hand-edited entry of "/" or "/home/<user>" was a recursive delete of that path.

Fix: new exported clientAssetBaseDirs(descriptor, homeDir); removeClientAssets(dests, baseDirs) now takes the bases and reports any dest failing containment as failed rather than removing it. Containment is strictly beneath a base, so a dest equal to the skills dir is refused too, since no write this module makes can produce that.

3. Two newly-added em dashes. (nit)
action_attach.js:263 and action-attach.test.js:154, against the CLAUDE.md rule. Both replaced; git diff origin/master...HEAD | grep -cP '^\+.*\x{2014}' is now 0. Pre-existing em dashes elsewhere in those files left alone.

Tests added: a leave test asserting the marker's dests are removed while an unmarked manual copy survives byte-intact, and a reverse() test with three poisoned marker entries (an escaping dir, the skills dir itself, and /) asserting failed and that every file survives. LLP 0138 #marker-undo updated to state the generalized rule the code now follows.

Needs your decision (not touched)

4. LLP 0107 §currency is not implemented, but the PR marks 0107 as implemented.
isCurrent (action_attach.js:225) compares only marker.endpoint to ctx.endpoint, and the reconciler skips any done and current marker. Adding a plugin to central config restarts the daemon but does not change the endpoint when the port is pinned or is the LLP 0114 well-known default, so materialization never re-runs. LLP 0107 §currency explicitly promises the opposite: "a plugin the org adds months later lands its skills without anyone re-running login. A login one-shot was rejected for exactly this."

Either fold an asset-set signal into isCurrent (for example a hash of [kind, name, client, dest] on the marker, compared against what the live registries would produce), or narrow the claim: drop "LLP 0138 is also where this doc was first implemented" from the Extended-by: line and note in LLP 0138 Consequences that #currency is still open, with a follow-up issue.

5. hyp attach still installs nothing on a daemon-managed install.
clients.js:260 short-circuits at the "already attached" branch with continue, before the new materialization at 306. You flagged this yourself in the PR body. Either move the materialization above the continue (it is idempotent and cheap), or leave it and point at hyp skills install in the "nothing to do" message.

Checked and cleared

Path containment for a malicious contribution name (isWithinDir plus isSafeContributionName, both tested); all literal expansion across all three call shapes; the copy-failure-is-not-fatal path against #failure-is-not-fatal; reverse() failure keeping the marker for retry; the hyp agents install removal (no dangling references, and walkthrough_picker_to_first_query still asserts the preserved skills.install span); all new @ref anchors resolve; the new file's @import uses the repo-root-anchored .js form.

Two pre-existing issues, widened by this PR but not introduced by it

Not filed as findings since this PR only relocates them, but both now fire on every org-driven attach: baseDir = path.join(homeDir, assetDir) is never checked for containment under homeDir, so a manifest with skill_dir: "../../etc" escapes (the defense-in-depth comment at client_assets.js:78 does not cover the manifest-supplied half); and asset destinations ignore the <CLIENT>_HOME env overrides that client_settings_path.js honors for settings, so a CODEX_HOME user gets settings in one tree and skills in another.

Separately: all 11 leave-command.test.js tests fail locally on a host with no systemctl, because runLeave step 2's serviceDaemonStatus call sits outside the surrounding try/catch, so the throw aborts leave past step 1. Pre-existing at master, not caused by this PR, and green in CI. Possibly worth its own issue if you want leave to degrade gracefully without a service manager.


Findings 4 and 5 are yours to decide. Reply on this thread and neutral will pick your answer up on its next tick.

neutral-loop and others added 2 commits July 27, 2026 05:22
Round-2 review of 4752921 found that round 1 stopped one call site short
and that the LLP paragraph it added overclaimed. Fixes, in the reviewer's
order:

1. `hyp detach` orphaned org-installed assets. `detachClientViaCore`
   cleared the attach marker without ever reading `installed_assets`, so
   detaching on a joined machine reversed the settings and stranded the
   copies. The read-then-remove moves INTO that routine, next to the
   clear, so both verbs get it from one place: the duplicated block (and
   the `clearMarker` option it needed) is gone from `runLeave`. Manual
   copies record no marker and still survive.

2. `hyp leave`'s plugin-gone branch destroyed the undo record silently.
   Refusing to remove without a descriptor is right (no base dirs to
   bound a recursive rm), but the marker was the only thing naming those
   files, so it now prints them before dropping it.

3. A failed re-`perform()` dropped `installed_assets` when rewriting the
   marker, so a later reversal removed nothing. The field is carried
   forward, and `runLeave`'s "a failed marker never applied an effect"
   comment (now false for attach) is corrected: such a marker takes the
   normal reversal path. The reconciler's own reverse gap still drops a
   failed marker without reversing it; that is left open on purpose and
   documented, not papered over.

4. One HOME fallback on the removal side: `reverse()` used
   `ctx.env.HOME ?? ''` while the CLI undo used `?? os.homedir()`, the
   exact drift 4752921 set out to prevent. And an empty `baseDirs` now
   reports "no asset directories resolved" instead of blaming
   containment for a cause that is not containment.

5. LLP 0138 #marker-undo re-stated against what the code now does:
   the rule binds whoever drops the marker, degrades to naming the paths
   where removal is impossible, and names the one drop that still
   escapes it.

Tests: `hyp detach` removes exactly the marker's assets while an
unmarked manual copy survives, and keeps the marker when a recorded
path cannot be removed. 2652 -> 2654 passing, tsc clean.

Co-Authored-By: Claude <noreply@anthropic.com>
A machine-specific symlink (node_modules -> /work/hypaware/node_modules)
was committed with the round-2 review fixes. .gitignore lists
'node_modules/' with a trailing slash, which matches a directory but not
a symlink of the same name, so it was never ignored.

Anyone checking out this branch would get a dangling symlink into a path
on the machine that pushed it. Removing it; no source change.
@philcunliffe

Copy link
Copy Markdown
Contributor

neutral review round 2: findings (5 new, 4 fixed and pushed, 1 for you)

Reviewed 4752921, which was neutral's own round-1 fix commit, by a fresh reviewer that was told to assume it was wrong. It found that round 1 was applied one call site short and that a doc line round 1 added was false. Those are neutral's defects, not yours, so they are fixed rather than handed back. Head is now 2e203af.

First, an error of ours worth flagging plainly

The round-2 fix commit 99aacec accidentally committed a node_modules symlink pointing at an absolute path on the machine running neutral. .gitignore lists node_modules/ with a trailing slash, which matches a directory but not a symlink of the same name, so nothing caught it. Anyone checking out this branch would have gotten a dangling symlink into a stranger's filesystem. Removed in 2e203af, which touches nothing else. Flagging it rather than quietly amending, since it was briefly on your branch. You may want node_modules without the trailing slash in .gitignore, but that is your call and outside this PR.

Round-1 fix audit

The three round-1 fixes hold up: the marker really is read before it is cleared (snapshotted well before resetCentralLayerToSeed, which never touches client-actions.json), a failed removal really does preserve the marker and stay retryable, hyp detach was genuinely unchanged, and unmarked manual copies really do survive. Containment resists .. segments, trailing slashes, relative dests, and symlinked dests, and refuses nothing the write side can actually produce. The em dash sweep was complete across the whole PR, not just the two cited lines.

Fixed and pushed

1. hyp detach orphaned org-installed assets. (major)
Round 1 fixed runLeave and stopped there. detachClientViaCore (clients.js:429) cleared the attach marker without ever reading installed_assets, so hyp detach claude on a joined machine reversed the settings, deleted the marker, and stranded the copied skills permanently. The same defect, in the sibling command, in the function round 1 had just edited.

Fixed by moving the read-then-remove into detachClientViaCore itself and deleting the duplicated block from runLeave, which also collapses the clearMarker option back out of the signature. Both commands now converge on one undo path. Removal is gated on attachProbe, mirroring reverse(). Two tests added: assets recorded by a marker are removed while an unmarked manual copy survives, and a removal that fails keeps the marker and exits nonzero.

2. The plugin-gone branch destroyed the undo record silently. (minor)
central.js:436 dropped a marker carrying installed_assets with no removal and no mention of the files. Refusing to remove without a descriptor is right, since there are no base dirs to bound a recursive delete, but destroying the only record of what was copied is not. It now prints each recorded path before clearing so they can be removed by hand.

3. A failed re-perform() lost installed_assets. (minor)
action_reconciler.js:177 rewrote a marker to failed without carrying the field, so a later hyp leave removed nothing, and the comment in runLeave claiming "a failed marker never applied an effect" was no longer true for attach. The field is now carried forward, the comment corrected, and the fast-drop path narrowed to failed markers that recorded no assets.

4. HOME fallback drift and a misleading reason. (nit)
Round 1 left reverse() on ctx.env.HOME ?? '' while runLeave used ?? os.homedir(): two readers of one field disagreeing, exactly the drift round 1 set out to prevent. Unified. When baseDirs is empty, the refusal now says so explicitly instead of blaming containment.

5. LLP 0138 #marker-undo was false. (doc)
Round 1 wrote that the rule binds whoever drops the marker, which detach and the plugin-gone branch both violated. The code changes above make it true where it can be, and the paragraph now names the one remaining escape (the reconciler's reverse gap on a failed marker) as unresolved instead of implying it is handled.

Needs your decision (unchanged from round 1, plus one new)

A. A containment refusal makes hyp leave permanently unfinishable. If a recorded path fails containment, every removal is reported failed, leave exits 1, and the marker is kept. The message promises a re-run will retry, but a containment refusal is deterministic: it fails identically forever, with no documented escape but hand-editing client-actions.json. Meanwhile the retained marker still reads status: done while its settings effect has already been reversed, which is the stale-marker state clients.js:423 documents as blocking re-attach after a later hyp join (#217). An I/O failure is retryable and a containment refusal is not, and they should probably not share a message. Which way that resolves is a judgment call about your undo semantics, so it is left to you. The related reconciler reverse gap (a failed marker carrying assets can still be dropped) is the same call.

B. LLP 0107 §currency is unimplemented while the PR marks 0107 implemented. isCurrent compares only marker.endpoint, so a plugin the org adds later never re-materializes, which is the scenario §currency explicitly promises and for which a login one-shot was rejected. Either add an asset-set signal to isCurrent, or narrow the Extended-by: claim and note in LLP 0138 that §currency is still open.

C. hyp attach still installs nothing on a daemon-managed install. clients.js:260 short-circuits before the materialization at 306. You raised this yourself in the PR body.

Checks

npm test 2654 pass, 0 fail, 1 skipped, and tsc --noEmit clean at 99aacec, against a 2652-pass baseline: exactly the two new tests, no existing test changed state. Local runs need a stub systemctl on PATH, since runLeave step 2's serviceDaemonStatus throws outside its try/catch on a host without a service manager, which fails all of leave-command.test.js locally. That is pre-existing at master and green in CI, but it may be worth its own issue if you want leave to degrade gracefully. CI is still running at 2e203af.


Neutral has hit its two-round review limit, so it will not keep editing this branch. Items A, B, and C are yours. Reply on this thread and neutral will pick your answer up on its next tick.

@philcunliffe philcunliffe added the neutral:changes-requested neutral reviewed an adopted PR and requests changes (non-binding; maintainer decides) label Jul 27, 2026

@philcunliffe philcunliffe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Neutral reviewed this PR twice at 2e203af and has reached its two-round limit, so it stops editing the branch here. The verdict is non-binding: you and the maintainer decide what happens next.

What neutral already fixed and pushed (details in the two review comments above): hyp leave and hyp detach both reverse the client assets their attach marker records, with a containment re-check on the recursive delete; the undo record survives a failed removal and a failed re-perform(); the plugin-gone branch names the files it cannot remove; and LLP 0138 #marker-undo now matches the code. Tests added for each. CI is green at this head.

Three things are yours to decide. None are defects neutral can settle without knowing your intent:

  1. Undo semantics on a containment refusal. A recorded path that fails containment makes hyp leave exit 1 and keep the marker, and the message promises a re-run will retry. But that refusal is deterministic, so it fails identically forever with no escape but hand-editing client-actions.json. Meanwhile the retained marker still reads status: done after its settings effect was reversed, which is the stale-marker state blocking re-attach after a later hyp join (#217). An I/O failure is retryable and a containment refusal is not, and they probably should not share a message or a resolution. The reconciler's reverse gap, where a failed marker carrying assets can still be dropped, is the same call.

  2. LLP 0107 §currency. This PR marks LLP 0107 implemented, but isCurrent compares only marker.endpoint, so a plugin the org adds later never re-materializes. That is exactly the scenario §currency promises and for which a login one-shot was rejected. Either add an asset-set signal to isCurrent, or narrow the Extended-by: claim and record in LLP 0138 that §currency is still open.

  3. hyp attach on a daemon-managed install. It still short-circuits at "already attached" before materializing, so it installs nothing. You raised this yourself in the PR body and asked for a call.

One error of neutral's own, flagged in the round-2 comment and already reverted: a node_modules symlink to an absolute path on the machine running neutral was briefly committed to this branch. .gitignore lists node_modules/ with a trailing slash, which does not match a symlink of that name.

Reply on this thread with your decisions and neutral will pick them up on its next tick.

…tics, attach short-circuit)

A. A removal that failed and one that was refused are different outcomes.
removeClientAssets marks each failure retryable or not: an fs.rm error keeps
the marker and fails the command, a containment refusal names the paths and
lets the marker go. Keeping a marker for a refusal made the undo permanently
unfinishable and left the stale done marker that blocks re-attach (#217).
Same split in reverse(). This also closes the reconciler's reverse gap: a
failed marker carrying installed_assets now reverses instead of being dropped,
which is safe now that a reverse which can never succeed completes.

B. LLP 0107 currency is implemented, not narrowed. perform() records
assets_key, a sorted digest of the planned copies, and isCurrent compares it
beside the endpoint, so a plugin the org adds later re-materializes at an
unchanged port. planClientAssets() is split out of materializeClientAssets()
so the digest and the copy read one loop.

C. hyp attach materializes before the already-attached short-circuit, so a
daemon-managed install no longer installs nothing.

readInstalledAssets moves to action_reconciler.js beside the marker store;
the reverse gap needs it, and the generic reconciler should not import a
specific handler.
@philcunliffe

Copy link
Copy Markdown
Contributor

neutral is stuck: its review budget is spent, and its next mechanical act would be a false signal

What neutral was doing. Driving this adopted PR (neutral:adopt) up the reconcile ladder. It reviewed twice, pushed fixes both times, and at the two-round cap posted a request-changes verdict at 2e203af naming three open items. You then pushed 3406261, which re-opens the ladder.

Why it cannot proceed. The rung the CLI computes for the new head is request-changes again, but only because the review-round counter is a lifetime count per PR that a contributor push does not reset. Acting on that literally would post a second blocking changes-requested review citing findings you have already fixed. Neutral will not do that: it would be a false claim, and the whole reconciler rests on never asserting something it has not verified.

It also cannot approve. Its two-round budget is exhausted, so it has not reviewed 3406261, and approving an unreviewed head would be the same failure in the other direction.

What neutral did verify at 3406261 (ground truth only, not a review):

  • CI: 8 of 8 checks green, confirmed against this commit's own check-runs rather than the possibly-stale rollup. mergeable: MERGEABLE.
  • Item A, refusal versus failure: removeClientAssets now marks each failure retryable, an fs.rm error keeping the marker and a containment refusal naming the paths and releasing it, annotated @ref LLP 0138#refusal-is-not-failure. The commit also closes the reconciler reverse gap neutral had flagged as unresolved.
  • Item B, LLP 0107 currency: perform() records assets_key and isCurrent compares it beside the endpoint, so this is implemented rather than narrowed.
  • Item C, attach short-circuit: materializeAttachAssets is now called inside the already-attached branch (clients.js:274) before it continues.

That is symbol-level confirmation that each item was addressed. It is not a judgment that the implementations are correct, and the roughly 625 added lines and four new test files have had no review pass.

What neutral needs from you. One of:

  1. Take it from here. The three items you were asked about are addressed and CI is green. Merge or review at your discretion. Note the standing CHANGES_REQUESTED review from 2e203af is now stale and will need dismissing, since it refers to items you have since fixed. Neutral left it alone rather than dismissing a review on your behalf.
  2. Spend another round. Reply saying so and neutral will do a full review of 3406261 on its next tick, budget cap notwithstanding.
  3. Something else you would rather it do.

Neutral has removed the now-inaccurate neutral:changes-requested label. It has added no verdict for this head, because it has no honest one to give.

Worth flagging separately: the round counter being per-PR-lifetime rather than per-head-lineage is arguably a tooling bug. Every future contributor push to a cap-exhausted PR lands in this same position, where the only mechanical move available is a false one.

How to unstick. Reply with a comment on this PR, or push to the branch. Neutral monitors this thread and will re-engage with your guidance on its next tick.

@philcunliffe philcunliffe added neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human and removed neutral:changes-requested neutral reviewed an adopted PR and requests changes (non-binding; maintainer decides) labels Jul 27, 2026
@platypii platypii removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Jul 27, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor

neutral review round 3: findings (1 blocking, 6 non-blocking, nothing pushed)

A third round, past neutral's two-round cap, taken because a human cleared the neutral:stuck label without answering the three questions. Neutral's budget for editing this branch stays spent: nothing was pushed, and everything below is handed back rather than fixed.

The three items you resolved in 3406261 were audited individually. A (refusal versus failure) and C (attach short-circuit) are genuinely resolved. B (LLP 0107 currency) is resolved as to mechanism but introduces a new defect. The commit's fourth claim, about the reverse gap, does not hold.

Blocking

1. A re-attach silently truncates installed_assets, orphaning the withdrawn plugin's files. src/core/config/action_reconciler.js:145-151.

The done marker is rebuilt fresh from outcome.detail with no carry-forward, while the failed rewrite at :181-191 deliberately carries installed_assets across. perform() (action_attach.js:207) sets the field to only what that pass copied.

Item B is what makes this reachable. Shrinking the plugin set changes assets_key, which fires a re-perform(), which rewrites the marker with the shorter list, while the withdrawn plugin's copy stays on disk with nothing naming it. hyp leave, hyp detach, and reverse() can then never remove it. That is exactly the orphaning LLP 0138 #marker-undo exists to forbid, and it undercuts LLP 0107 §consent, where org influence is supposed to be reversible.

Verified empirically against the real handler and reconciler at this head:

after both: [".../skills/claude/helper-a", ".../skills/claude/helper-b"]
after drop: [".../skills/claude/helper-a"]
helper-b still on disk: true

Fix: union the record on the done path exactly as the failed path already does. Seed the marker at :145 with ...(existing?.installed_assets ? { installed_assets: existing.installed_assets } : {}) and have the handler merge rather than replace. Re-recording an already-removed path is harmless: the removal side re-checks containment and fs.rm uses force: true.

No currency test removes a skill; every one adds. That is why this slipped through.

Non-blocking, but the doc must not ship as written

2. The reverse-gap justification is false in three places. The commit message says the gap is safe "now that a reverse which can never succeed completes." It does not complete. reverse() has three deterministic failed exits this commit did not touch: missing descriptor (action_attach.js:311-315), missing attachProbe (:316-325, the #212 decision), and a detach() throw. A failed marker carrying installed_assets for a client whose descriptor is gone now fails reverse every pass, forever, logging client_action.reverse_failed at error level each time. That is the "retained forever" outcome the old comment cited as the reason to leave the gap open.

Proven: three consecutive passes, outcome: failed, reason: no client descriptor for 'claude' to reverse, marker present every time.

The degradation the justification describes exists only in the CLI hyp leave path (central.js:441-462) and only for the asset-removal sub-case. The same false claim appears in the code comment at action_reconciler.js:218-234 and in llp/0138-client-assets-one-install.decision.md:124-127. Round 2 caught a false doc sentence; this is a repeat of the pattern. Harm is bounded (a permanently failed entry in hyp status, one error log per pass, and a failed marker does not block re-attach so #217 is not reintroduced), which is why it is not classed blocking. Under the living-docs rule it should still not merge as written. Either correct the sentence to say the gap is closed at the cost of retaining a marker whose reverse can never succeed and why that beats orphaning, or make reverse() complete when the descriptor is permanently unavailable, matching what hyp leave does.

3. NO_BASE_DIRS is marked unretryable although one of its two causes is transient. client_assets.js:230-235 returns retryable: false whenever baseDirs is empty, but clientAssetBaseDirs (:170-177) returns [] either because the descriptor declares no asset dirs (deterministic) or because homeDir is '' (environmental). The ?? fallbacks at action_attach.js:367 and clients.js:406 do not catch HOME=''. The surviving comment at action_attach.js:363-366 says the removal side "must not turn a missing HOME into a containment refusal over files that are really there", which is now exactly what happens, and the marker is dropped over it.

4. "Costs a stat pass" understates what hyp attach now does. llp/0138…:72 and the comment at clients.js:270-271. copyAsset (client_assets.js:324-332) unconditionally does fs.rm(dest, {recursive: true, force: true}) plus copyDir per skill. So a no-op hyp attach claude on an already-attached daemon-managed install now silently destroys local edits under an org-contributed skill directory, where before that branch did nothing. Consistent with hyp skills install replace semantics, so read as intended, but the doc should say so.

5. The currency digest is blind to asset content. clientAssetsKey() digests kind:name:client:dest only, not source or content. A pinned-version bump that rewrites an existing skill's bytes without changing the set produces an identical key, so isCurrent stays true and the new bytes never land. LLP 0107 §currency's headline still reads "re-runs materialization when the pulled config changes the plugin or client set" while the new paragraph narrows it, so the doc is internally inconsistent. Worth a Consequences bullet naming the limit and pointing at hyp skills install.

6. Two implementations of "the client an action names". action_attach.js:505-508 adds attachActionClient() whose docstring says isCurrent() must resolve it the same way, but perform() at :140-144 still inlines its own copy. Equivalent today; it is the duplication the helper was introduced to prevent.

7. Cosmetic. Two separate import statements from client_assets.js at clients.js:13 and :15; const detail at :511 computed unconditionally but used only in the retryable branch; and the --json already-attached payload at :247-258 still reports changed: false and is written before the materialization at :274, so a machine consumer sees changed:false for a run that recopied assets.

Test gaps

The strong tests are real: attach-endpoint-drift.test.js:164 proves both the currency fire and the settle across four passes, and attach-live-port.test.js:191 is a genuine end-to-end for item C. But the retryable half of the new split has no test at all (grep -rn retryable test/ finds only comments) because round 2's marker-survives test was repurposed into the refusal test. There is also no mixed retryable-plus-refusal test, no shrink-direction currency test (finding 1), and the reverse-gap test uses a stub handler that always returns done, so it validates routing but never the premise the change rests on. A test with the real createAttachHandler() and no descriptor would have caught finding 2 immediately.

Verified clean

No em dashes on any added line across the whole diff. No statement-terminating semicolons. No inline import('...') types, no @typedef added. The new @import in action_attach.js:26 is correctly repo-root-anchored. All eight @ref LLP 0107#… / 0138#… anchors resolve to real anchors. readInstalledAssets moved to action_reconciler.js:380-384 cleanly, no import cycle, all three callers updated. planClientAssets() split duplicated no filter, containment, or all-expansion logic. Full suite with a stub systemctl: 2658 tests, 2657 pass, 0 fail, 1 skipped. CI at this head is green and is the authority.

Flagged for a human, not findings

hyp agents install is removed with no alias, a deliberate breaking CLI change argued in LLP 0138 #one-command, worth a release-note line. And pre-existing, out of scope here: a probe-less descriptor still keeps its done attach marker forever on hyp detach and hyp leave (clients.js:527, the #212 versus #217 tension), the same "undo that can never finish" shape item A fixed for refusals. Worth its own issue.


Finding 1 is the one that blocks. Its fix is roughly one line plus a merge in perform(). Reply on this thread and neutral will pick up your guidance on its next tick.

@philcunliffe philcunliffe added the neutral:changes-requested neutral reviewed an adopted PR and requests changes (non-binding; maintainer decides) label Jul 27, 2026
…reverse-gap claim

The blocking defect: the reconciler rebuilds a `done` marker from the outcome
detail alone, and perform() reports what that pass copied. The two differ once
the desired set shrinks, so an attach re-fired by a withdrawn plugin rewrote
the marker with the shorter list while the withdrawn files stayed on disk with
nothing naming them. The `failed` path already carried the field forward; the
`done` path now unions it, in the same one place, for every handler. Covered by
a shrink-direction currency test, which fails without the union.

The previous commit message, a code comment, and LLP 0138 all claimed closing
the reverse gap was safe because a reverse that can never succeed now
completes. That is false: reverse() fails deterministically when the descriptor
is gone or has no attach_probe, neither of which this branch touched. Such a
marker now retries every pass rather than being dropped once. That is the
accepted trade, not an oversight, and it is written down as one: a retained
`failed` marker is visible and does not block re-attach, whereas dropping it
destroys the record of files that are really there. Only the asset half
degrades to naming-and-releasing; the settings half cannot.

Also documents the currency limit: the digest covers the asset set, not its
bytes, so an in-place skill rewrite under an unchanged set does not re-fire.
@philcunliffe philcunliffe added neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) and removed neutral:changes-requested neutral reviewed an adopted PR and requests changes (non-binding; maintainer decides) labels Jul 27, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor

neutral verdict at cc69a95: approved, held for the maintainer

The blocking finding from round 3 is fixed and independently verified. Neutral did not take the author's new test as proof; it built its own probe and ran it against both commits.

At 3406261, before the fix:

after both  : ["helper-a","helper-b"]
after drop  : ["helper-a"]
helper-b on disk after reverse: true    -> orphaned

At cc69a95:

after both  : ["helper-a","helper-b"]
after drop  : ["helper-a","helper-b"]
helper-b on disk after reverse: false   -> removable

The union is also correct beyond the case the test exercises. action_reconciler.js:167-170 is the only done-marker write site in the repo, so the carry-forward covers every handler rather than attach alone. Dedup on exact dest strings is sound because every dest comes from the same path.join in planClientAssets. Growth is bounded by the distinct paths the org has ever shipped to that client and clears when the marker is dropped. Re-recording an already-removed path is harmless: removal re-checks containment and fs.rm uses force: true, so it lands in removed, not failed. A re-attach on a boot with no skills registry now also preserves the old record instead of wiping it.

The corrected reverse-gap wording is honest this time, checked claim by claim against the code. reverse() does return failed for both a missing descriptor and a missing attach_probe; the forward gap short-circuits only on status === 'done', so a retained failed marker genuinely does not block re-attach; and only the asset half degrades to naming-and-releasing while the settings half fails hard. Rounds 2 and 3 each caught a false doc sentence on this PR; there is no third. Stating the retained-marker behavior as an accepted trade, with the reasoning, rather than defending the original claim, is the right disposition.

CI is green at this head against the commit's own check-runs, and the PR is mergeable.

Deferred, and yours to weigh

Four non-blocking findings from round 3 remain unaddressed. None can cause a production defect, which is why this is an approval and not a hold, but they are real and worth a follow-up rather than silent loss:

  1. NO_BASE_DIRS is unretryable although one of its two causes is transient. client_assets.js:230-235 marks every dest unretryable when baseDirs is empty, but that happens either because the descriptor declares no asset dirs (deterministic) or because homeDir is '' (environmental). The surviving comment at action_attach.js:363-366 says the removal side must not turn a missing HOME into a containment refusal over files that are really there, which is now what happens, and the marker is dropped over it.
  2. "Costs a stat pass" understates hyp attach. copyAsset does rm -rf plus re-copy per skill, so a no-op attach on an already-attached daemon-managed install silently destroys local edits under an org-contributed skill directory. Consistent with hyp skills install semantics and read as intended, but the doc should say so.
  3. Duplicated client resolution. attachActionClient() was added with a docstring saying isCurrent() must resolve the client the same way, but perform() still inlines its own copy. Equivalent today; it is the duplication the helper exists to prevent.
  4. Cosmetic. Two separate imports from client_assets.js in clients.js; a const detail computed unconditionally but used in one branch; and the --json already-attached payload reporting changed: false for a run that recopied assets.

The content-blind currency digest from round 3 was addressed, as documentation, via a new LLP bullet.

Also worth a release-note line, flagged and not a finding: hyp agents install is removed with no alias, a deliberate breaking CLI change argued in LLP 0138 #one-command.

What neutral did not do

Neutral will not merge or ready a contributor's PR; that stays yours. This verdict is non-binding.

It also did not re-review the whole PR at this head. Rounds 1 through 3 covered 8efd687, 4752921, and 3406261; this pass was a bounded check of the three files in cc69a95 against the one blocker and the doc correction. Neutral's editing budget for this branch was spent at round 2, so nothing here was pushed by it.

perform() inlined the same params-or-request-key resolution the new
attachActionClient() helper exists to own. No behavior change; the emptiness
guard stays, since it never was part of the resolution.
@platypii
platypii merged commit 052acc8 into master Jul 27, 2026
8 checks passed
@platypii
platypii deleted the client-assets-one-install branch July 27, 2026 18:29
@philcunliffe philcunliffe added the neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) label Jul 27, 2026
bgmcmullen added a commit that referenced this pull request Jul 28, 2026
…hold (LLP 0101/0100)

An onboarding session could not show data on the server. The enrolling
login writes a first-sync hold marker (LLP 0101), and the driver returns
`{ sinks: [], held: 'first_sync_hold' }` before it ever consults
`force: true`, so `hyp sink force` printed "no sinks instantiated;
nothing to do" - which is the opposite of the truth - and nothing could
end the window early. On an evening enrollment the 4-hour floor rolls the
deadline to the *next* day's 11:59pm, so "wait until midnight" could mean
28 hours.

LLP 0101 #no-release rejected exactly this verb, for three reasons. Two
survive: the printed message stays unconditionally true (reworded to "no
later than"), and release-on-review-completion still needs a completion
signal from the skill that a finished skill cannot honestly give. The
third - "costs only latency on a machine that was not forwarding
yesterday either" - is what this concedes. On an attended onboarding the
hold blocks the demonstration that the product works, and the window is
sized for a review (hypaware-privacy) too slow to run in the meeting the
hold was scoped to. #which holds the attended lane while unattended
`hyp join` forwards immediately, which is backwards for that case.

LLP 0069 R6 ("not forwarded, even once") is untouched: R6 forbids a
*silent* first forward, and an explicit confirmation naming the
destination is the consent it exists to obtain. The daemon never clears
the marker, so an unattended machine still waits out the full window.

`hyp sync [instance] [--yes] [--dry-run]` replaces `hyp sink force`
rather than aliasing it: both were one `driver.tick({force: true})`, and
a second spelling preserves the confusion it was introduced to fix
(the #399 doctrine). It always confirms - one rule beats a prompt whose
appearance the user has to predict - and prints what it is about to send
first: every destination named, annotated off-machine only on the
evidence of an http(s) URL and on-machine only on the evidence of a path,
plus the directories being withheld. "Are you sure?" with nothing to be
sure about is a keystroke, not a decision. `hyp sink maintain` stays;
snapshot expiration is not an export tick.

The three surfaces that print the deadline (login message, wizard privacy
narration, `hyp status`) now name the verb, and the formatted deadline
carries its time zone - ECMA-402 forbids mixing timeZoneName with
dateStyle/timeStyle, so the zone comes from a second formatter.

`purge` and `report delete` had grown one copy each of the same y/N
prompt; all three now share src/core/cli/confirm.js with their question
text unchanged.

LLP 0101 #no-release is amended with the original preserved as a quote,
and LLP 0100 R2 ("No export tick may run before the deadline") with it -
it was flatly false once this lands. R1 now requires the zone and the
release verb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bgmcmullen added a commit that referenced this pull request Jul 28, 2026
…hold (LLP 0101/0100) (#419)

* hyp sync: one export verb, and a confirmed way out of the first-sync hold (LLP 0101/0100)

An onboarding session could not show data on the server. The enrolling
login writes a first-sync hold marker (LLP 0101), and the driver returns
`{ sinks: [], held: 'first_sync_hold' }` before it ever consults
`force: true`, so `hyp sink force` printed "no sinks instantiated;
nothing to do" - which is the opposite of the truth - and nothing could
end the window early. On an evening enrollment the 4-hour floor rolls the
deadline to the *next* day's 11:59pm, so "wait until midnight" could mean
28 hours.

LLP 0101 #no-release rejected exactly this verb, for three reasons. Two
survive: the printed message stays unconditionally true (reworded to "no
later than"), and release-on-review-completion still needs a completion
signal from the skill that a finished skill cannot honestly give. The
third - "costs only latency on a machine that was not forwarding
yesterday either" - is what this concedes. On an attended onboarding the
hold blocks the demonstration that the product works, and the window is
sized for a review (hypaware-privacy) too slow to run in the meeting the
hold was scoped to. #which holds the attended lane while unattended
`hyp join` forwards immediately, which is backwards for that case.

LLP 0069 R6 ("not forwarded, even once") is untouched: R6 forbids a
*silent* first forward, and an explicit confirmation naming the
destination is the consent it exists to obtain. The daemon never clears
the marker, so an unattended machine still waits out the full window.

`hyp sync [instance] [--yes] [--dry-run]` replaces `hyp sink force`
rather than aliasing it: both were one `driver.tick({force: true})`, and
a second spelling preserves the confusion it was introduced to fix
(the #399 doctrine). It always confirms - one rule beats a prompt whose
appearance the user has to predict - and prints what it is about to send
first: every destination named, annotated off-machine only on the
evidence of an http(s) URL and on-machine only on the evidence of a path,
plus the directories being withheld. "Are you sure?" with nothing to be
sure about is a keystroke, not a decision. `hyp sink maintain` stays;
snapshot expiration is not an export tick.

The three surfaces that print the deadline (login message, wizard privacy
narration, `hyp status`) now name the verb, and the formatted deadline
carries its time zone - ECMA-402 forbids mixing timeZoneName with
dateStyle/timeStyle, so the zone comes from a second formatter.

`purge` and `report delete` had grown one copy each of the same y/N
prompt; all three now share src/core/cli/confirm.js with their question
text unchanged.

LLP 0101 #no-release is amended with the original preserved as a quote,
and LLP 0100 R2 ("No export tick may run before the deadline") with it -
it was flatly false once this lands. R1 now requires the zone and the
release verb.

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

* Review round 1: the hold releases only on unscoped, attended consent

Six findings from neutral's review of e25b45b.

1. BLOCKER. `hyp sync <instance>` released the driver-wide hold after a
   plan built from the one named handle. `hyp sync parquet` on a machine
   with a central sink showed "parquet /home/u/exports (stays on this
   machine)", and a `y` there ended the window for `central` too - the
   next daemon tick then forwarded the whole backfilled history with
   nothing on screen. That is the silent first forward the hold exists to
   prevent (LLP 0101 #hold: "one missed forward hold is a permanent
   leak"), and it falsified this PR's own headline property.

   A scoped run now refuses while a hold is live and says why. Releasing
   is all-or-nothing because the hold is; rendering the full destination
   list from a command that ticks one sink would have been the other
   option, but "I asked for one sink and it released everything" is a
   surprise the refusal avoids entirely.

2. A failed unlink turned a confirmed sync into a silent exit 0: the
   marker survived, the driver held the tick, and the loop over
   `report.sinks` printed nothing. That is the defect this PR opens by
   describing, reintroduced one path over. The clear is now checked, the
   marker re-read, and `report.held` inspected after the tick; each is an
   error with the marker path, not a quiet success.

3. The release emitted no telemetry, and clearing the marker destroys the
   only on-disk evidence it happened, so nothing afterwards distinguished
   "the window expired" from "somebody ran hyp sync". Logs
   `sync.first_sync_hold_released` with the deadline, how early it was,
   and the destination counts.

4. `--yes` released a live hold, which the clause titled "Release only by
   confirmed, attended request" does not license. It now refuses while
   held. `--yes` still works for ordinary syncs; what it must not buy is
   somebody's review window.

5. Three prose surfaces still said the deadline was the only way the
   window ends: README, and the hypaware-privacy skill in both the claude
   and codex copies - the skill being the surface LLP 0100 designates for
   the review itself, telling a user mid-review that their window cannot
   end early.

6. The plan printed the raw server URL that R1a was landed to remove.
   R1a binds the login's surfaces by its text, but its reason is about
   terminals, and this prompt appears at the same onboarding moment:
   servers are now named from `query.remotes` by origin, with the
   `hyp remote list` pointer, falling back to the host when no name is
   configured.

Both refusals run before the plan renders: a scoped plan is the
misleading artifact finding 1 is about, and printing "syncing now ends it
early" ahead of "you cannot end it this way" reads as a contradiction.
`--dry-run` is exempt from both - it sends nothing, so showing a held
machine what one destination would export is information, not consent.

LLP 0101 #no-release and LLP 0100 R2 record the two shapes that cannot
release and why, so the constraint is not left living in code comments.

Tests: the instance-plus-hold combination the review identified as never
exercised, plus --yes-plus-hold, an unclearable marker, and the two
naming paths. 16 in the file, 2760 in the suite.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) 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