Uh oh!
There was an error while loading. Please reload this page.
Narrow the retired-asset prune to direct children, and stop reading unreadable as gone (#746) - #749
Conversation
…nreadable as gone (#746) Three narrowings and two recorded residuals from the ship review of PR #745, none of them reachable as user-data loss on the shipped head. - `isRemovableAsset` now admits only a direct child of a client asset directory, which is the exact shape the copy side writes. A corrupt ledger record naming `<base>/<dir>/subdir` with a matching digest could otherwise drive a recursive delete of a subtree nobody installed. - A candidate whose digest cannot be read is no longer mistaken for one that is already gone. Only ENOENT drops the record in silence; every other errno carries the record forward verbatim (no digest re-taken) and reports the path withheld with a `digest_unreadable` kind. - A config-enabled plugin whose directory is wholly absent is taken as retired, which is the intended reading rather than a fifth stand-down door. Recorded in LLP 0219 and pinned by a test. LLP 0219 also gains the two doc-only residuals the review asked for: the wizard finale prunes on the pre-wizard config, and the digest-check-then-rm race. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 13, 2026
neutral review - round 1Head reviewed: Headline: finding 1 is a real widening of a delete predicate, reproduced Finding 4 is a decision I am escalating rather than absorbing: item 3's reading Review round 1 of VERDICT: findings 1. major - |
…oping (#746) Closes the six round-1 review findings on #749: - isRemovableAsset now keeps isWithinDir as a conjunct alongside the dirname check, so a basename beginning with ".." (a prefix the copy side's containment check already refuses) is not widened back in by the dirname-only direct-child check. - inspectClientAsset splits the top-level fs.stat probe into its own try/catch, so an ENOENT raised while walking or reading below dest can never be misread as dest itself being gone. - LLP 0223 is minted to carry the #only-direct-children and #unreadable-is-not-absent narrowings out of the Accepted LLP 0219, which now carries an Extended-by forward-ref and stubs pointing at the new doc; #uninstalled-is-retired gains one sentence on transient absence. - The cannot-be-read test's chmod(0o000) now registers its t.after restore immediately, before any assertion can leave a mode-000 directory behind. Every new/changed test was verified to fail against the pre-fix code and pass against the fix: the ".." basename tests fail by actually deleting the user's file/directory pre-fix (not just an assertion mismatch), and the new inspectClientAsset test mocks a readFile ENOENT below dest (a literal dangling symlink at dest turns out to be caught by the top-level stat in both old and new code, so it does not discriminate the fix) and fails pre-fix by silently dropping the ledger record. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 13, 2026
Cleanup done; worktree clean at VERDICT: findings Round 2 of Headline: round 1's finding 1 is genuinely fixed, and I re-derived the fix from scratch rather than reading it. An exhaustive old-vs-new comparison over One item the triage rung must decide, not a defect: round 1 escalated 1. minor — PR #749 description — the body still presents the pre-fix predicate as what ships, and still asserts the claim round 1 falsifiedNot ship-blocking on code: returnresolved!==base&&path.dirname(resolved)===baseas the shipped Why it matters: this PR series' own retrospective is "a prior PR in this series shipped a guarantee its body could not support." Landing a description that documents a delete predicate as narrower-by-one-conjunct than it is repeats that precisely. Exact fix — update the description: replace the code block with the three-conjunct body, replace the "strict conjunct, checkable by reading" sentence with the reason the third conjunct is required ( 2. nit — |
philcunliffe
commented
Aug 13, 2026
neutral triage - ship, with one pre-merge task for the humanReview budget exhausted at 2 rounds. Three residual findings plus one open The open question, and why it did not blockRound 1 escalated LLP 0219's Blocking cannot prevent the behaviour. The transient-absence class is far narrower than the abstract shape, verified in What is lost is recoverable and is only our own bytes. The four-condition gate Absence of sign-off is therefore a process gap, not a production defect. The ship The pre-merge taskThis PR's description still shows the pre-fix two-conjunct This series' own retrospective is that a prior PR shipped a guarantee its body could The other two residualsBoth deferred to #755: the discarded errno on a below- Verified at head |
Uh oh!
There was an error while loading. Please reload this page.
This branch merged master mid-flight and inherited two documents both claiming LLP 0223: the converge-on-applied-config decision (PR #770, landed first) and the prune-direct-children-and-unreadable-assets decision (PR #749, landed second). CI's duplicate-numbers check and test/core/llp-ref-hygiene.test.js both fail on the collision. Per LLP 0156#renumber, the later claimant moves. 0226 is already spoken for by fix/issue-774, a sibling branch fixing the same collision on master directly, so this renumbers to 0227, the next free number above the highest claimed across origin/master and every remote branch. Mechanical rename only: no content, status, date, or reasoning changed. The inbound sweep retargets the Extended-by header and four body links in LLP 0219, two @ref [implements] annotations in src/core/runtime/client_assets.js, one in src/core/runtime/client_asset_ledger.js, and five @ref [tests] annotations in test/core/client-assets-prune.test.js. References to LLP 0223 that mean the converge decision (src/core/config/apply.js, src/core/cli/wizard/join.js, src/core/cli/remote_commands.js, test/core/remote-login-command.test.js, llp/0129, llp/0135) are untouched. Co-Authored-By: Claude <noreply@anthropic.com>
`master` carried two documents claiming LLP 0223. Per LLP 0156#renumber the later claimant moves: the converge decision reached master first (348b0ae, PR #770, 2026-08-13T23:16), the prune decision second (f581e4c, PR #749, 2026-08-14T10:36). The prune decision moves to 0226, the next free number above the highest claimed on origin/master and every remote branch (0225 is the highest; 0221 is unclaimed but numbers are minted as max + 1). Mechanical rename only, which CLAUDE.md and LLP 0156 allow on an Accepted document: no content, status, date, or reasoning changed. The inbound sweep retargets 13 references in 4 files - the `Extended-by:` header and four body links in LLP 0219, five `@ref ... [tests]` annotations in test/core/client-assets-prune.test.js, two `@ref ... [implements]` in src/core/runtime/client_assets.js, and one in src/core/runtime/client_asset_ledger.js. The eight remaining `LLP 0223` references all mean the converge decision, which keeps the number. Before: `git ls-tree -r origin/master --name-only llp/ | ... | uniq -d` prints 0223, and `no LLP number is claimed by two documents` fails. After: the duplicate check prints nothing and llp-ref-hygiene.test.js is 11/11 green, including `every @ref resolves to a live LLP document and one of its anchors`. Co-authored-by: test <test@test.com> Co-authored-by: Claude <noreply@anthropic.com>
…773) * Wizard prompts take their printed default at EOF instead of hanging (#772) `rl.question()` leaves its promise permanently unsettled when the input stream ends without a line, so the three legacy readline prompts in `src/core/cli/walkthrough.js` hung forever on a spent stdin: the overwrite confirm (`hyp init < /dev/null` never returns), the defaults gate, and the backfill consent. The same file already solves this for the numbered picker with `queuedLineAsker`, which resolves a pending ask as `null` on `close` and seeds `closed` from `readableEnded` so an interface built over an already-ended stream does not wait on an `end` it will never see. All three prompts now read through that helper and coalesce `null` to the empty line, so EOF takes exactly the default the question printed (`[y/N]` -> no, `select [2]` -> option 2, `[Y/n]` -> yes) and the branch cannot drift from the advertised default. Output is byte-identical: `queuedLineAsker` writes the prompt itself, the way `rl.question` did. test/core/walkthrough-prompt-eof.test.js races each prompt against a timer, because the pre-fix failure is a hang rather than a wrong value. 4 of its 7 cases fail on master and all 7 pass here. Co-Authored-By: Claude <noreply@anthropic.com> * Renumber the colliding prune decision from 0223 to 0227 This branch merged master mid-flight and inherited two documents both claiming LLP 0223: the converge-on-applied-config decision (PR #770, landed first) and the prune-direct-children-and-unreadable-assets decision (PR #749, landed second). CI's duplicate-numbers check and test/core/llp-ref-hygiene.test.js both fail on the collision. Per LLP 0156#renumber, the later claimant moves. 0226 is already spoken for by fix/issue-774, a sibling branch fixing the same collision on master directly, so this renumbers to 0227, the next free number above the highest claimed across origin/master and every remote branch. Mechanical rename only: no content, status, date, or reasoning changed. The inbound sweep retargets the Extended-by header and four body links in LLP 0219, two @ref [implements] annotations in src/core/runtime/client_assets.js, one in src/core/runtime/client_asset_ledger.js, and five @ref [tests] annotations in test/core/client-assets-prune.test.js. References to LLP 0223 that mean the converge decision (src/core/config/apply.js, src/core/cli/wizard/join.js, src/core/cli/remote_commands.js, test/core/remote-login-command.test.js, llp/0129, llp/0135) are untouched. Co-Authored-By: Claude <noreply@anthropic.com> * Revert "Renumber the colliding prune decision from 0223 to 0227" This reverts commit 5ce4283. * Review: the EOF examples name a run these prompts never reach `hyp init < /dev/null` was cited in both the overwrite confirm's JSDoc and the test header as the run this change unhangs. It is not: the wizard's first screen is `runWizardFork`, whose `legacyMenuPrompt` (src/core/cli/wizard/fork.js) still reads through `rl.question` and so still hangs a fully unanswered `hyp init` one screen before any of the three prompts fixed here. What these three do fix is real and reachable: `hyp clients enable` reaches the backfill consent directly through `maybeBackfillAfterEnable` with no TTY gate, and a partially scripted wizard run (fork answered, stdin then dry) reaches the express gate, the defaults gate and the commit-point confirm. The examples now name those instead, and the test header records the fork prompt as the remaining member of the class. Comment-only: no behaviour, no output bytes, all 7 cases still pass. Co-Authored-By: Claude <noreply@anthropic.com> * Review round 2: the replacement EOF example names a run that is also unreachable Round 1 replaced `hyp init < /dev/null` with `hyp clients enable < /dev/null` in the test header. That run is wrong twice over: there is no `clients` command (`hyp attach <client>` is the one that enables), and the backfill consent is not reachable on a piped stdin at all - `maybeBackfillAfterEnable` runs only when `activatedViaPrompt` is set, and both sites that set it go through `maybeInteractiveEnableAttach`, which returns early on `!isTty(ctx.stdin)`. Verified: `hyp attach claude < /dev/null` exits 1 on the not_enabled refusal without asking anything. The header now names the run actually probed on this branch - answer the fork, let stdin dry, and the express gate, the commit-point confirm and the backfill consent all settle on their printed defaults over the spent stream - and states the narrower terminal-drop shape that reaches the attach caller. LLP 0190 #sync-gate said these three prompts "still call `rl.question` directly and still hang at EOF", which the code this PR annotates with that very anchor contradicts. 0190 is Draft, so the sentence is corrected in place and now records the fork screen as the one prompt left outside the file. Comment and doc only: no behaviour, no output bytes. npm test 4091/0 fail, npm run typecheck clean. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: test <test@example.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: test <test@test.com>
Closes#746. Three narrowings and two recorded residuals from the ship review of #745. Nothing here widens the four-condition predicate: items 1 and 2 each add a conjunct or split a branch that previously fell through to a delete or to silence.
LLP 0219 is merged and settled, so every doc change below is additive: three new bullets in the Decision section, and two paragraphs that state something the existing anchors left implicit. Nothing it decided is edited.
1. Prune candidates must be a direct child of an asset base dir
isRemovableAssetadmitted any strict descendant of a base dir. The materializer only ever writes<base>/<name>or<base>/<name>.md, so a ledger record naming anything deeper cannot be describing our own write - and with a digest that matches, it drove a recursivefs.rmof a subtree nobody installed.The
resolved !== baseterm is kept rather than folded into thedirnamecomparison.path.dirname('/')is'/', so a degenerate base would otherwise match itself, and dropping the term would have made this the one edit in the set that could widen. Keeping it makes the new predicate a strict conjunct of the old one, which is checkable by reading.Verification that the narrowing is right (the issue made this a precondition of shipping it): the only construction of a destination anywhere in the tree is
planClientAssets,path.join(baseDir, asset.name)/path.join(baseDir,${asset.name}.md).copyDirhas exactly one caller (copyAsset,src/core/runtime/client_assets.js). Bothskills.registerandagents.registerreject any name that is not a single safe path segment (isSafeContributionName,src/core/runtime/activation.js:269,312). The marker'sinstalled_assetsis likewise the materializer's owndestlist (action_attach.js:195,214), so the detach path - the other caller ofremoveClientAssets- cannot be handing it a grandchild either. No materializer path writes a grandchild.Judgement call: I narrowed
isRemovableAssetitself, which also narrowsremoveClientAssets(used byhyp detachand the reconciler's reverse). The alternative was to narrow only insidepruneOneAssetand leave detach admitting descendants. Rejected: detach's input is the same persisted JSON from the same writer, so a grandchild there is the same corruption signal, and two different containment rules on one module is the drift LLP 0138 collapsed four loops to prevent.Judgement call: the refusal messages now read "resolves outside this client's asset directories, or deeper into them than HypAware writes". A grandchild is not literally "outside", and a refusal on a corrupt install record is exactly the diagnostic a human reads. The alternative (a second message keyed on which condition failed) adds a branch to a corruption path for no user benefit.
Discrimination
Test:
a recorded destination deeper than a direct child is refused, digest or no digest. Fixture: a user-owned~/.claude/skills/my-own-skill/reference/notes.md, plus a ledger record naming.../my-own-skill/referencewith a real matching digest, so conditions 1, 2 and 4 all hold and only depth can stop it.Reverted the hunk by hand (restored the old
isWithinDir-based body) and re-ran:Pre-fix the user's file is deleted. Restored: 21/21. The test also asserts the retired direct child
hypaware-ignoreis still pruned in the same run, so it is not passing by the prune standing down.Note on the fixture: the issue frames this as a subtree of a currently planned skill. That exact fixture cannot be built, because
copyAssetdoesfs.rm -rfon a planned skill dir before copying, so a grandchild under a planned skill is destroyed by the copy loop before the prune ever runs. The user-owned directory is the reachable version of the same defect, and it is the worse one: nothing in the subtree was ever ours.2. "Digest unreadable" is no longer read as "already gone"
digestClientAssetreturnsundefinedboth for a path that is not there and for one that is there but unreadable.pruneOneAssettreated both as gone: it dropped the ledger record and said nothing, so the copy became permanently unprunable and unreportable, which is the leave-behind class LLP 0219 exists to end.inspectClientAsset(new, inclient_asset_ledger.js) returns{ digest?, missing }.digestClientAssetis now a one-line wrapper over it, so there is still one hash implementation and every existing caller is untouched. OnlyENOENTsetsmissing; every other errno carries the record forward and reportsclient_assets.prune_withheldwitherror_kind: digest_unreadableplus a stderr line.The carried record cannot itself become a delete. It is carried verbatim - no digest is taken of what we could not read - so a later run's removal is still gated on the digest recorded at the moment we wrote the bytes. This is the same move LLP 0219 #open-marker-self-heal explicitly rejects for marker paths ("record the digest of whatever is at the path on first sight"), and not doing it is the point. The test asserts the carried record's
digestfield is byte-identical to the one the install wrote.Judgement call: only
ENOENTcounts as gone.ENOTDIR(a parent component is a file) also means the asset is not there, but treating it as unreadable errs toward "remove less, report more", and it is unreachable in practice for a dest that is a direct child of an existing asset directory.Discrimination
Test:
a retired asset that cannot be read is named and kept on the books. Fixture: install two skills, thenchmod 000a directory inside the retired one, so the top-level stat succeeds (the copy is plainly still there) andhashTreefails withEACCESinside it.Neutralized the hunk by hand (
if (missing || !digest) return { carried: undefined, removed: false }, i.e. the pre-fix behaviour) and re-ran:Pre-fix stderr is empty: the record is dropped in complete silence. Restored: 21/21.
The test has three phases and the last two both discriminate: (a) the withheld report appears on stderr, (b) the ledger record survives with its original digest, (c) with permissions restored and the bytes back to what was installed, the next run prunes it and says so - which pre-fix is impossible, because run 2 dropped the only record naming the path.
Not verified: this test needs a directory the running user cannot read. It calls
t.skip(...)ifreaddirstill succeeds afterchmod 000(a root-owned CI run). It did not skip here (uid 1001): all runs above show# skipped 0for this file. If CI runs as root this test is a no-op there, and I have no way to check that from this worktree.3. The fifth door for
unavailablePlugins: taken as intended, and pinnedA config-enabled plugin whose directory is wholly absent is in neither
unloadable(nothing failed to load) nor the withheld-by-profile term (nothing was there to withhold), sounavailablePluginsdoes not name it and its ledgered assets prune as retired.Per the issue's disposition I did not add
configEnabled - poolto that list.boot.jsis unchanged. The reading is recorded in LLP 0219 under a new adjacent anchor{#uninstalled-is-retired}next to#incomplete-activation-prunes-nothing(rather than inside it: the four routes there are all ways a plugin present on the machine leaves a boot's plan, and a new case does not belong under settled text), and pinned by a test.Discrimination
Test:
a config-enabled plugin that is no longer installed is retired, and its skill prunes. Two real boots over the existing synthetic bundled workspace: boot 1 installs and ledgers the opt-in plugin's skill, then the plugin's whole directory is deleted and boot 2 materializes again.There is no fix hunk to revert for a pin test, so I discriminated it against the alternative the issue rejects: I temporarily added
...[...configEnabled].filter((name) => !pool.some((m) => m.manifest.name === name))tounavailablePluginsinboot.jsand re-ran:Reverted; 21/21. The test asserts both halves:
unavailablePluginsis empty (the reading), and the skill is gone (the consequence). Either edit toboot.jstrips it.4 and 5. The two doc-only residuals
Both are additive paragraphs in LLP 0219, each citing #746 so a later reader lands on the item rather than re-deriving it.
#incomplete-activation-prunes-nothing, immediately after the paragraph that documents the intersection, since that is the sentence it qualifies. Recorded as accepted (transient, self-healing, HypAware-written bytes only), not fixed.#edited-assets-are-not-ours, where the gate is described, explicitly labelled an accepted residual so a future review reaches the line instead of re-litigating it.No new LLP was needed: nothing here required changing what 0219 settled.
Safety claims, and exactly which test proves each
Stated narrowly, because a prior PR in this series shipped a guarantee its body could not support:
a recorded destination deeper than a direct child is refused, digest or no digest.a retired asset that cannot be read is named and kept on the books.a config-enabled plugin that is no longer installed is retired, and its skill prunes.I am not claiming anything broader about hand-edited assets; the digest gate that covers that case is unchanged by this PR and its existing tests are unchanged.
What I could not verify
chmod 000directory. Not exercised here.docs/ACCEPTANCE.mdcovers the prune, and I did not run one.walkthrough_picker_to_first_queryfails, and it is pre-existing. Verified bygit stash push -uand re-running on the pristine basec483c1a, where it fails on the identical assertion (config: Phase 5 picker config matches expected shape). Prune retired client assets that HypAware itself installed (#726) #745's body reports the same. Not touched by this PR.Checks (fresh
npm installin the worktree)Smokes chosen by
grep -l "client_assets\|clientAssets\|skills install\|attach" hypaware-core/smoke/flows- that is the full list of flows that mention client assets or attach.🤖 Generated with Claude Code