Skip to content

feat(distill): agent-driven merge architecture - #13

Closed
cad0p wants to merge 68 commits into
Michaelliv:mainfrom
cad0p:feat/agent-driven-merge
Closed

feat(distill): agent-driven merge architecture#13
cad0p wants to merge 68 commits into
Michaelliv:mainfrom
cad0p:feat/agent-driven-merge

Conversation

@cad0p

@cad0p cad0p commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the per-file LLM merge driver with an agent-driven merge architecture. The distill agent now owns the full distill→merge→squash→push pipeline. Removes the per-file merge driver entirely; the agent has the conversation context the driver always lacked.

Includes the race-step10-fix sub-PR (8 race-fix commits added 2026-05-18) that closes a wrapper↔JS-poller race and replaces the prompt-only verify:agent-prompt gate with the full-runtime verify:e2e gate.

Architecture changes

  • Agent owns merge. The agent runs steps 1-9 of the distill prompt (commit → merge main into distill branch → squash to main → push). Conflicts resolve in the agent's session with full conversation context, not in a stateless merge driver.
  • Wrapper owns cleanup. Worktree + branch removal happens in the wrapper's EXIT trap (happy path) and salvage() (failure path). Agent doesn't run cleanup commands itself — that was the source of the production race fixed in this PR.
  • Salvage never touches main vault history. V3-locked: salvage logs HEAD-on-default but never git checkout/git reset on the user's vault.
  • Single config knob. distill.maxDurationMinutes (default 10) is the only setting agent-driven merge introduces.

Why per-file merge driver was removed

Live-debugging PR #11 (ed8af2f) surfaced two problems:

  1. Provider prelude scales linearly with conflict count. Empirically: kiro/claude-sonnet-4-6 has ~95s prelude on every pi -p call (no cross-process OAuth cache). N conflicted *.md = N × 95s of dead time. A 5-conflict merge ate 8 minutes of prelude alone.
  2. Stateless merge driver loses conversation context. Git invokes the driver per-file with three text fixtures (%O, %A, %B). The agent that wrote %A's content cannot inform the merge resolution.

Net change: roughly net-zero LOC (~870 LOC merge driver + tests + .gitattributes registration deleted; ~870 LOC wrapper validation + salvage + new prompt + bash-stub fixtures + integration tests added). Structural simplification, not raw line reduction.

Race-step10-fix sub-PR (commits c3cc816..92b6a9c)

A production distill emitted Warning: Distillation terminated abnormally — no outcome record despite the underlying distill succeeding. Investigation revealed a race between the agent's step-10 worktree removal and the wrapper's write_outcome. Same shape in the salvage path.

Fix:

  • Drop step 10 from the agent prompt — the wrapper's EXIT trap was always the actual cleanup mechanism; the agent's step-10 was redundant and the source of the race.
  • Reorder salvage() to write outcome BEFORE any worktree removal.
  • Invariant: write_outcome always runs before any worktree-removal anywhere in the wrapper.

Test architecture:

  • verify:agent-prompt (prompt-only, 741 LOC) replaced with verify:e2e (full-runtime, 773 LOC) — exercises wrapper subprocess + real-LLM agent + 2-second setInterval JS poller + bare-origin push.
  • New wrapper-invariant.test.ts (422 LOC, 2 tests) — uses subprocess + concurrent filesystem polling (raw spawn, not spawnSync-blocking helpers) to pin the invariant on both happy and salvage paths.

Methodology lesson (now codified in the methodology guide as an anti-pattern): real-LLM gates are part of orchestrator self-review, not deferred to maintainer. The previous prompt-only gate passed 3/3 against the unfixed code because its shape never exercised the wrapper↔JS-poller seam.

Verification

  • bun test424 pass / 0 fail (was 420/0 on main; +4 tests added by race fix)
  • bunx tsc --noEmit — clean
  • bun run lint — clean
  • HOME=$(mktemp -d) bun test — clean (CI-portability gate)
  • bun run verify:e2ePASS (140.1s wall, 9/9 post-conditions): severity=info, msg="Distillation complete (140s)", outcome=merged-content, origin/main advanced
  • bun test extensions/distill/system-prompt.cache-parity.test.ts — 9/0 (baseline-regression gate)

What was deleted

  • scripts/merge-driver-*.ts (per-file LLM merge driver + tests)
  • scripts/verify-agent-prompt.ts (prompt-only gate; superseded by verify:e2e)
  • .gitattributes merge-driver registration
  • 6 of 9 v0.1.1 deferred items (obsolete after architectural change)

Sub-PR design artifacts

Full design + 5 rounds of design review for the race fix:

  • Parent design: Goldmine/features/pi-napkin-distill/pr-12-agent-driven-merge/design.md
  • Race fix sub-design: Goldmine/features/pi-napkin-distill/pr-12-agent-driven-merge/race-step10-fix/design.md

Test plan

bun run verify:e2e is the on-demand integration gate; orchestrator runs it before declaring ready, ~$0.50/run, single-run policy. Run shape exercises wrapper + real LLM + JS-side polling end-to-end.

Closes

Closes #11 follow-up (the merge-driver scaling + provider-prelude problem PR #11 surfaced). The agent-driven merge architecture supersedes the per-file approach.

cad0p added 30 commits April 18, 2026 16:02
The two napkin kb tools did not register a `renderResult` callback, so
`ToolExecutionComponent` fell back to `createResultFallback` which dumps
the full text output unconditionally. Ctrl+O had nothing to toggle.

Add a shared `formatKbResult` helper that truncates to N lines when
`!expanded` and appends a "… (N more lines, Ctrl+O to expand)" hint,
mirroring the built-in `read` / `grep` / `ls` / `find` / `bash` tools.

Collapsed line counts:
- kb_search: 15 lines (matches `grep`)
- kb_read:   10 lines (matches `read`)
Remove vault-resolve.ts and use napkin-ai's findVault() directly,
which now supports $XDG_CONFIG_HOME/napkin/config.json as a global
vault config fallback.

This eliminates duplicated vault resolution logic and ensures
consistent behavior between the napkin CLI and pi-napkin extensions.

- Delete extensions/vault-resolve.ts
- napkin-context: use new Napkin(cwd) directly
- distill: use new Napkin(cwd).vault.configPath for config access

Requires napkin-ai >= 0.9.0 (with global config support).
- Update vault resolution section to reflect napkin-ai's native support
- Add migration guide from ~/.pi/agent/napkin.json to ~/.config/napkin/config.json
…notes

- Read _about.md files to understand folder purposes
- Use relevant folder path when creating notes
- Append to today's daily note in the relevant namespace
- Minor punctuation cleanup
…nfig (#5)

Make the distill model config optional. When omitted, the spawned pi
subprocess will use whatever model pi resolves by default (the user's
configured default), instead of always falling back to a hardcoded
anthropic/claude-sonnet-4-6.
Guide the model on what _about.md files should look like — short folder
descriptions explaining what kinds of notes belong there. Points the
model at existing examples for style reference.
pnpm v10 blocks lifecycle scripts in dependencies by default unless
they are listed in onlyBuiltDependencies. Because napkin-ai is a
git-hosted dependency (github:cad0p/napkin), pnpm needs to run its
'prepare' script to build dist/ after fetch. Without the allowlist,
pi fails to install pi-napkin with:

  ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED Failed to prepare git-hosted
  package ... napkin-ai@0.8.1 needs to execute build scripts but is
  not in the 'onlyBuiltDependencies' allowlist.

Declaring napkin-ai in pnpm.onlyBuiltDependencies lets pnpm build it
during install. npm users are unaffected (the 'pnpm' key is ignored).
…update README (#8)

* docs(readme): point install snippet at the cad0p forks

- Napkin link updated from Michaelliv/napkin to cad0p/napkin.
- Install snippet updated: napkin from @cad0p scope with both pnpm and
  npm variants; pi-napkin installed via git from cad0p/pi-napkin rather
  than npm:pi-napkin, which resolves to the upstream package and would
  install the wrong fork.
- Minor: drop the -ai suffix from prose references ("napkin's built-in
  vault resolution") since the fork publishes as @cad0p/napkin.

* chore: depend on @cad0p/napkin from npm, drop pnpm build-script workaround

Now that @cad0p/napkin is published to the npm registry, pi-napkin
depends on it by name + semver range rather than as a git URL. The
registry tarball ships `dist/` prebuilt, so there's no `prepare`
lifecycle to run at install time and the pnpm.onlyBuiltDependencies
workaround from #7 is no longer needed.

Version range is `*` to match the other peer deps — @cad0p/napkin is
under the same owner, so locking pi-napkin behind a range would just
create busywork. npm's range matcher excludes prereleases from `*`,
so calver builds from the `next` dist-tag don't accidentally get
pulled — consumers stay on the stable `latest` until a base release.

- package.json: "napkin-ai": "github:cad0p/napkin" → "@cad0p/napkin": "*"
- package.json: drop pnpm.onlyBuiltDependencies (no longer applies)
- package-lock.json: regenerated — resolves to the registry tarball
- extensions/distill/index.ts: import updated to @cad0p/napkin
- extensions/napkin-context/index.ts: same
- skills/napkin/SKILL.md: install line now `pnpm add -g @cad0p/napkin`
  (or `npm install -g @cad0p/napkin`)
…re-vault footgun (#9)

* docs(skill): document vault resolution and first-time setup

The skill skipped over napkin's vault resolution logic, which contains a
silent auto-creation footgun: running any vault-resolving command (e.g.
`napkin vault`) from a directory with no ancestor `.napkin/` and no
global `~/.config/napkin/config.json` makes napkin create a bare vault
at cwd \u2014 `.napkin/` + `NAPKIN.md` + `.obsidian/` \u2014 with no prompt.

This is easy to hit at first setup: an agent running `napkin vault` from
the user's home or a random project directory litters that directory
with empty-vault artifacts.

Additions:
- New "Vault Resolution" section near the top of the skill, listing the
  full 4-step resolution order (--vault flag -> ancestor .napkin ->
  global config -> bare-vault auto-creation), with an explicit warning
  callout about step 4.
- "First-time setup" subsection showing the global config file format
  (`~/.config/napkin/config.json` with a `vault` field) and how to
  confirm resolution with `napkin vault --json | jq -r .path` before
  running commands that might trigger the fallback.
- Cleanup instructions for when the bare-vault fallback has already
  fired: delete the stray `.napkin/`, `NAPKIN.md`, `.obsidian/`.
- "Config" section rewritten to distinguish the two configs \u2014 per-vault
  (`<vault>/.napkin/config.json`, managed by `napkin config`) vs.
  global vault-path (`~/.config/napkin/config.json`, set manually).
- `--vault <path>` flag description now cross-references the Vault
  Resolution section instead of giving a misleading one-line summary.

* docs(skill): fix wrong `napkin version` invocation

The skill listed `napkin version` as a subcommand, but the CLI exposes
version as a flag `--version` (short: `-v`). Running `napkin version`
returns "error: unknown command 'version'".
…10)

* chore(lint): sort imports in napkin-context after @cad0p/napkin rename

Biome's organizeImports flags the @cad0p/napkin import as out of order
since PR #8 renamed the dependency from 'napkin-ai'. The old sort order
placed 'napkin-ai' after @mariozechner/*, but the new @cad0p/* scope
sorts first. Drive-by fix to unblock CI for downstream branches.

* feat(distill): add /distill-auto-this-session to toggle auto-distill

Adds a per-session toggle for the automatic distillation timer. State
is persisted into the session file via a CustomEntry so that turning
auto-distill off, quitting pi, and resuming the same session keeps it
off.

  /distill-auto-this-session           toggle on <-> off
  /distill-auto-this-session on        turn auto-distill on
  /distill-auto-this-session off       turn auto-distill off
  /distill-auto-this-session status    report state and time to next run

Only suppresses the scheduled timer. Manual /distill still works.

Status bar shows 'distill: off (session)' when off for the session,
deliberately distinct from 'distill: off' which means distill is
disabled in the vault config. On resume (turn auto-distill on), the
countdown resets so the next run respects the full interval instead
of firing immediately. The in-flight poll loop owns the status bar
while a distill runs -- renderIdleStatus short-circuits on isRunning
to avoid flicker if the user turns auto-distill off mid-run.

If distill is disabled in the vault config, the command warns that
the session flag has no effect until vault-level distill is enabled.
Transitions use 'info' severity since they're user-requested;
'warning' is reserved for the vault-disabled state and bad args.

Persistence:
- State is stored as a CustomEntry (type: "custom") with customType
  'napkin-distill-session-state' and data { suppressed: bool }.
  CustomEntry is the documented pi mechanism for extension state
  persistence (session-format.md); unlike CustomMessageEntry it does
  not participate in LLM context.
- session_start reads the latest matching entry on the current branch
  (via getBranch(), the canonical state-restoration pattern used by
  todo/summarize/handoff/qna examples) and restores the flag. Branch-
  scoped reads avoid leaking state across abandoned branches.
- Schema is append-only: readers stop at the first matching customType
  and fall back to default on malformed data, so a newer writer is
  never silently shadowed by an older valid entry.
- The handler only writes on real state changes, so idempotent calls
  don't bloat the session file.
- Forked sessions inherit the state via SessionManager.forkFrom copying
  all entries; users can turn auto-distill back on in the fork.

Session lifecycle:
- autoDistillSuppressed is reset at the top of session_start so it
  doesn't leak across session switches that take early-return paths.
- isRunning is reset in session_shutdown so an in-flight distill at
  shutdown doesn't stick 'true' across in-process session switches.
- On session_start with reason resume/fork/startup, an off session
  emits a one-time notify so the user doesn't miss the status bar
  after a long gap.
)

* chore(test): add bun test infrastructure

pi-napkin had zero tests. Add bun test (matches @cad0p/napkin's stack) so
upcoming distill work (shouldDistillOnShutdown predicate, worktree
lifecycle) has a place to live.

Also migrate package manager from npm to pnpm per repo steering:
- Drop package-lock.json in favor of pnpm-lock.yaml
- Update CI + release workflows to use pnpm + setup-bun for tests

The trivial smoke test in extensions/distill/index.test.ts just wires
up bun test. Real distill tests land in later phase-a commits.

phase-a

* feat(distill): add onShutdown config field (default true)

Add `distill.onShutdown: boolean` to DistillConfig with default `true`.
Export DistillConfig, VaultConfig, DEFAULT_DISTILL, and loadVaultConfig so
config parsing is unit-testable from extensions/distill/index.test.ts.

Schema + default only \u2014 nothing reads the field yet. Phase B wires it
into the shutdown handler via shouldDistillOnShutdown.

Tests cover:
- Missing config.json \u2192 defaults
- Empty distill block \u2192 onShutdown=true (default wins)
- Explicit opt-out (onShutdown=false)
- Missing onShutdown \u2192 default=true
- Malformed values (null, string, 0) \u2192 !== false \u2192 still runs
- Malformed JSON \u2192 full default fallback
- Other distill fields preserved when setting onShutdown

phase-a

* feat(distill): add shouldDistillOnShutdown predicate

Pure predicate that decides whether to spawn a final distill at
session_shutdown. No I/O \u2014 caller assembles all inputs. Ready for the
phase B shutdown handler to consume.

Guard order matches the spec (NAPKIN_DISTILL_NO_RECURSE, reload, session
suppression, enabled, onShutdown opt-out, sessionFile, size=0,
lastSpawnedSize, lastSessionSize). The lastSpawnedSize guard dedupes the
"shutdown right after interval fire" race without blocking the
"shutdown with new content since last spawn" case.

Test coverage: one test per guard (proves isolation), short-circuit
order tests (proves guard order), and integration scenarios (typical
/quit, /reload, just-spawned dedup, fresh session with no content,
disabled config, per-vault opt-out, per-session suppression).

Known deviation: pi's SessionShutdownEvent has no `reason` field in
0.67.1 (re-checked in node_modules). The predicate accepts an optional
`reason` on a local `ShutdownDistillEvent` interface \u2014 phase B will
have to detect reload externally (likely by tracking session_start with
reason=reload) and pass it in. Flagged for reviewer.

phase-a

* feat(distill): track lastSpawnedSize for shutdown dedup

Record the session file's byte size at the moment of a successful distill
spawn \u2014 set BEFORE the completion poll starts. Separate from
`lastSessionSize` (which updates on completion) so that a shutdown firing
between spawn-and-complete can dedupe against the in-flight distill
without waiting for it to finish.

Scaffolding only: written in runDistill, not yet read. Phase B wires it
into the shutdown handler via shouldDistillOnShutdown guard 8.

No unit test \u2014 runDistill isn't exported and has substantial side effects
(fork session, spawn subprocess). Testing in isolation needs heavy
mocking; phase B integration tests cover the write-then-read path end to
end. Per spec allowance. `biome-ignore noUnusedVariables` documents the
temporary state explicitly \u2014 phase B removes the suppression when it
adds the read.

phase-a

* test(distill): widen DEFAULTS types so Partial overrides compile

Literal-inferred types (`reason: undefined`, `enabled: true`) in the
shared DEFAULTS object made `call({ event: { reason: "reload" } })` and
similar override overrides fail strict tsc ("Type 'string' is not
assignable to type 'undefined'"). bun test runs them fine at runtime \u2014
this is purely a static-typing issue \u2014 but any future tsc-noEmit check
(or editor type-check) would flag them.

Widen DEFAULTS to an explicit `Inputs` interface matching the
predicate's parameter shapes. No behavior change; all 51 tests still
green.

phase-a

* chore(deps): bump pi peer dep to >=0.68.0 for session_shutdown.reason

Phase A's `ShutdownDistillEvent` shim worked around pi 0.67.1 lacking
`reason` on `SessionShutdownEvent`. The field was added in pi 0.68.0
(2026-04-20). Bump the peer dep floor to `>=0.68.0` and use pi's native
type directly.

- package.json: peer `@mariozechner/pi-coding-agent` becomes ">=0.68.0"
- pnpm-lock.yaml: refreshed (pulls 0.73.1 currently)
- should-distill-on-shutdown.ts: drop local `ShutdownDistillEvent`,
  accept `Pick<SessionShutdownEvent, "reason">` for caller flexibility
- should-distill-on-shutdown.test.ts: update test inputs to canonical
  reason literals; drop "undefined reason" case (impossible post-0.68)

51 \u2192 50 tests (one merged loop no longer tests impossible values).
pnpm install --frozen-lockfile, pnpm lint, pnpm test all green.

phase-b

* feat(distill): add distill-workspace module for worktree layout

Introduce `extensions/distill/distill-workspace.ts` \u2014 the module that
manages the on-disk layout used by per-distill isolated workspaces.
Milestone 2 of Phase B: no git involvement yet, just the directory
layout, session fork, and meta.json.

Exports:
- `createDistillWorkspace(vault, sourceSessionFile)` \u2014 mkdtemp root,
  fork session into `<wt>/.napkin/distill/session.jsonl`, write meta
- `cleanupDistillWorkspace(worktreePath)` \u2014 idempotent rm -rf
- `readDistillMeta(worktreePath)` \u2014 parse meta.json or null on any
  failure (missing, malformed, incomplete schema)
- `generateDistillBranchName(now?, nonceHex?)` \u2014 `distill/<hex6>-<epoch>`
- `DistillMeta` interface (pid, vault, branch, startedAt, parentSession)

Key implementation choices documented inline:
- Branch name carries a 24-bit random nonce so two distills firing in
  the same second never collide (timestamp-only is a latent race).
- `readDistillMeta` never throws on I/O absence; only on unexpected
  system errors. Callers treat null as "stale or gone".
- Tmp dir tag is `napkin-distill-<branch-suffix>-<random>` so orphans
  are traceable.
- Milestone 3 will replace the tmp-dir with a real git worktree; the
  public API stays.

Adds `distill-workspace.test.ts` (16 tests):
- branch name format + nonce collision-free at scale
- workspace layout, meta.json schema
- two workspaces from the same source are independent
- throws on missing source, cleans up tmp dir on fork failure
- cleanup is idempotent on missing paths
- readDistillMeta null-paths for missing \/ malformed \/ partial metadata

51 \u2192 66 tests. pnpm lint, pnpm test, pnpm install --frozen-lockfile
all green.

phase-b

* feat(distill): create per-distill git worktree

Milestone 3 of Phase B. Replace the tmp-dir placeholder from milestone 2
with a real git worktree rooted at the vault's HEAD. Each distill now
gets its own branch (`distill/<hex6>-<epoch>`) and isolated working
tree under `<vault>/.napkin/distill-worktrees/<branch-suffix>/`.

New exports in distill-workspace.ts:
- `createDistillWorktree(vault, branch, path)` \u2014 `git worktree add -b ...`
  with vault-has-git-repo precondition (throws DistillError otherwise)
- `removeDistillWorktree(vault, path, branch)` \u2014 worktree-first,
  then branch; prunes stale entries on failure; no-ops if vault gone
- `DistillError` class \u2014 distinguishable from stdlib errors
- `DISTILL_WORKTREES_SUBDIR` constant (`.napkin/distill-worktrees`)

`createDistillWorkspace` now:
- Chooses a unique branch name and path under the vault
- Calls `createDistillWorktree` before forking the session
- Rolls back (`removeDistillWorktree`) if the session fork / meta
  write fails, so failures never leak a worktree or branch
- Signature unchanged: still `(vault, sourceSessionFile) \u2192 handle`

`cleanupDistillWorkspace` signature shifts to `(vault, workspace)`
because cleanup needs both the vault and the branch name (the branch
name only lives on the returned handle, not the path). No callers yet
(added in milestone 5), so the API shift is free.

Tests: adds per-worktree git setup (init, seed commit) and covers:
- worktree add / remove happy path
- vault-without-.git throws DistillError
- branch-collision throws DistillError
- workspace rollback on fork failure (no leaked branch)
- cleanup idempotency on missing vault / missing paths
- worktree path lives under the vault (important for napkin cwd \u2192 vault
  resolution in the distill subprocess)

66 \u2192 73 tests. pnpm lint + pnpm test + pnpm install --frozen-lockfile
all green.

phase-b

* feat(distill): add LLM merge driver and git_retry wrapper

Milestone 4 of Phase B. Ship the two shell-layer pieces that make
worktree-based auto-distill safe:

`extensions/distill/scripts/napkin-distill-merge` \u2014 LLM-powered git
merge driver. Registered per-worktree via `git config --local`:
  merge.napkin-distill-merge.name   = "napkin distill LLM merge driver"
  merge.napkin-distill-merge.driver = "<abs-path> %O %A %B %P"
and activated by a `*.md merge=napkin-distill-merge` line in
`.gitattributes` (appended by `registerMergeDriver` when missing).

Driver contract:
- Reads base (%O), ours (%A), theirs (%B), filename (%P)
- Calls `pi -p <prompt>` (prompt preserves both sides, asks for de-dup
  and frontmatter validity)
- Sanity-checks the output: non-empty, length in [0.3, 3.0]x max input,
  frontmatter-at-top if all three inputs had frontmatter (syntactic)
- 3 strikes on any failure (pi exit !=0, empty, sanity fail)
- Exit 0: write resolved content to %A
- Exit 1: leave %A unmodified (caller's partial-merge salvage handles)
- Reusable test mocks via `NAPKIN_DISTILL_MERGE_MOCK` env var

`extensions/distill/scripts/git_retry.sh` \u2014 bash function that retries
main-mutating git commands on transient index.lock contention:
- Up to NAPKIN_GIT_RETRY_MAX (default 5) attempts
- Linear backoff base*attempt (default 0.5s; env-overridable)
- Max ~5s total wait \u2014 good for transient locks, tiny blast radius
  for real failures (exits fast after last attempt)
- Uses `awk` for float arithmetic (avoids bc dependency)

Integration with distill-workspace.ts:
- Add `MERGE_DRIVER_SCRIPT` import via new `scripts-paths.ts` (module
  that resolves script locations via `import.meta.url`, cwd-independent)
- `createDistillWorktree` now calls `registerMergeDriver` after
  creating the worktree, rolls back the worktree + branch on failure
  (unregistered driver would defeat the whole merge design)
- `.gitattributes` append is idempotent: if the line is already present
  (future: Phase C's auto-init scaffolds it), no change

Tests (`scripts.test.ts`, 12 new tests):
- napkin-distill-merge: ok, fail, empty, tiny, huge, no-fm, ok-after-2
  (3rd-attempt success), ok-after-3 (3-strike give-up)
- git_retry: happy path, always-fail hits retry cap, eventual success,
  REAL index.lock contention with a backgrounded releaser

73 \u2192 85 tests. pnpm lint + pnpm test + pnpm install --frozen-lockfile
all green.

phase-b

* feat(distill): implement worktree-based auto-distill spawn

Adds `spawnDistillInWorktree` \u2014 a detached spawn that performs the full
auto-distill lifecycle inside a per-distill git worktree so concurrent
distills (interval, shutdown, or multiple pi sessions) don't race on vault
files.

Shipped:
- `extensions/distill/scripts/distill-wrapper.sh`: bash orchestrator that
  runs pi in the worktree, commits, merges main back, squash-merges to main
  from the vault cwd, and cleans up via EXIT trap. Takes `<vault> <worktree>
  <branch> <sessionFork> <prompt> <errorDir> [<model>]`. Writes fatal
  failures to `<vault.configPath>/distill/errors/<ts>-<pid>-<branch>.log`
  and always completes cleanup regardless of failure path.
- `spawnDistillInWorktree({ vault, sessionFile, prompt, model?, spawnFn? })`
  in `distill-workspace.ts`: creates the workspace, resolves the vault error
  dir via Napkin, and spawns the wrapper detached with `NAPKIN_DISTILL_NO_RECURSE=1`.
  Parent returns synchronously with `{ workspace, pid }` and MUST NOT wait.
- `DISTILL_WRAPPER_SCRIPT` export in `scripts-paths.ts` so callers and tests
  resolve the script path via `import.meta.url` (test cwd agnostic).

Why:
- Existing `spawnDistill` (temp-dir based) isn't safe under concurrency. The
  worktree design isolates each distill on its own branch so git's own
  `.git/index.lock` + the LLM merge driver + git_retry handle interleaved
  main-HEAD writes. See Phase B Item 5 in the shutdown-distill spec.
- Kept `spawnDistill` untouched: the manual `/distill` command still uses
  it (git-optional), and future work (Phase C) can decide when to migrate it.
- Error dir lives on the MAIN vault (resolved via `Napkin(vault).vault.configPath`)
  because worktrees are removed on cleanup, which would otherwise lose the logs.

Self-healing scaffolding (temporary until Phase C's auto-init):
- The wrapper's `git add -A` excludes `.napkin/distill/` (session fork +
  meta.json) and `.napkin/distill-worktrees/` (sibling-worktree pool) via
  pathspec so they never enter the vault's git history. Phase C replaces
  this with a committed `.gitignore`.

Tests (+9, 85 \u2192 94):
- Unit: mock `spawn` to verify positional args, detached flags, env, cwd,
  errorDir creation, return value shape.
- Integration: real wrapper against a tmp git repo with pi stubbed via
  `NAPKIN_DISTILL_SKIP_PI=1`. Covers happy path (squash commit lands on
  main), empty distill (no commit, clean exit), two concurrent workspaces
  (both complete without interference), and missing-arg error (exit 2).

Next: Item 6 (partial-merge salvage integration test) and Item 7 (route
interval distill through worktree).

* feat(distill): partial-merge salvage keeps clean files, reverts conflicted ones to main

Adds integration-test coverage for the partial-merge salvage path that
already lives in distill-wrapper.sh (shipped with Item 5). The salvage code
runs after `git merge main` completes: any file still unmerged \u2014 meaning
the LLM merge driver 3-struck on it \u2014 is reverted to main's version via
`git checkout main -- <file>`, logged, and staged so the merge commit can
complete with partial content.

Tests (+2, 94 \u2192 96):
- "clean file keeps distill's content, conflicted file reverts to main":
  Seeds main with `clean.md` + `conflict.md`, creates a workspace, then
  diverges main's `conflict.md`. Distill mutates both. With
  `NAPKIN_DISTILL_MERGE_MOCK=fail` forcing 3-strikes, asserts that the
  squash commit on main keeps main's `conflict.md` content and gets
  distill's `clean.md` content, and that the error log names the
  conflicted file + "partial-merge" reason.
- "all files conflict: salvage reverts everything, no squash commit created":
  Verifies the empty-squash guard (Item 5's `git diff --cached --quiet`
  check on main before commit) survives the salvage path \u2014 when every file
  gets salvaged back to main's version, the squash has no net change and
  we correctly skip the commit.

Why this matters:
- LLM driver failures are a normal operating condition (the spec specifies
  3 strikes per file). Without salvage, a single bad merge would abort
  the whole distill and discard all its work. With salvage, the vault gets
  the work that merged cleanly and loses only the specific files the LLM
  couldn't handle.
- Error-log semantics: each reverted file gets its own line in the log so
  the forensic trail is file-granular, not distill-granular. Combined with
  the dangling-SHA log entry (Item 5), a user can `git cat-file -p <sha>`
  to recover the distill's version of any salvaged file within the git gc
  grace window (default 2 weeks).

Wrapper behavior confirmed by tests:
- Salvage loop runs `git checkout main -- <f>` + `git add -- <f>` per
  unmerged file, then `git commit --no-edit` to finalize the merge.
- MERGE_HEAD check (post-salvage) catches the driver-wrote-conflict-markers
  edge case and bails with a forensic log.
- EXIT trap always cleans up worktree + branch regardless of salvage outcome.

* feat(distill): route interval distill through worktree spawn

Adds `runAutoDistill(ctx)` alongside the existing `runDistill` (untouched)
and switches the interval timer to use it. Manual `/distill` command still
calls `runDistill` so vaults without git remain supported.

`runAutoDistill`:
- Pre-flight check: vault must have `.git/`. Missing git surfaces a
  one-line status hint ("distill: needs git") and returns without
  throwing. Phase C's auto-init will ensure git is always present.
- Spawns via `spawnDistillInWorktree` (Item 5) \u2014 each call creates an
  isolated git worktree on a fresh `distill/<hex>-<epoch>` branch.
- Polls `workspace.worktreePath` for disappearance as the completion
  signal: the wrapper's EXIT trap removes the worktree on any exit path.
- Bookkeeping mirrors `runDistill` exactly:
    `lastSpawnedSize` on successful spawn (pre-poll dedup for shutdown)
    `isRunning` while the worktree exists
    `lastSessionSize` on completion (post-completion dedup)
    `lastDistillTimestamp` on completion or no-op skip
- On 10-min timeout, calls `cleanupDistillWorkspace` to force-remove the
  stalled worktree + branch.
- Catches `DistillError` from the workspace layer separately from generic
  spawn failures to surface clearer status-bar text.

Imports from distill-workspace: `cleanupDistillWorkspace`, `DistillError`,
type `DistillWorkspace`, `spawnDistillInWorktree`.

Interval wiring (session_start):
  before:  intervalHandle = setInterval(() => runDistill(ctx),          intervalMs)
  after:   intervalHandle = setInterval(() => runAutoDistill(ctx),      intervalMs)

Manual `/distill` command wiring unchanged \u2014 still calls `runDistill`.

Tests (+2, 96 \u2192 98):
- `runAutoDistill vs runDistill routing (Item 7) > interval callback
  creates a worktree`: mocks the ExtensionAPI, stubs globalThis.setInterval
  to capture the interval callback, invokes it synchronously, and asserts
  `.napkin/distill-worktrees/<hex>-<epoch>` was created.
- `/distill command creates a tmp dir, NOT a worktree`: invokes the
  registered `/distill` command handler, asserts a `napkin-distill-*`
  tmp dir appeared under `os.tmpdir()` but no worktree was created
  under the vault.

The test ExtensionAPI mock is intentionally minimal \u2014 only `on()` and
`registerCommand()` are exercised by the extension during session_start
and command invocation. Other methods are stubbed with no-ops.

Race-free observation: both `runAutoDistill` and `runDistill` create
their respective artifacts (worktree / tmpDir) synchronously before
returning the spawned subprocess handle to the caller. The detached
wrapper's cleanup trap runs later, so post-return assertions are stable.

* feat(distill): wire shutdown handler to spawn auto-distill in worktree

Completes Phase B. The session_shutdown handler now consults
`shouldDistillOnShutdown` (Phase A predicate) and spawns a final
auto-distill via `spawnDistillInWorktree` (Item 5) when all guards pass,
capturing any session work added since the last interval distill \u2014 or all
of it, for short sessions that finish before the first interval fires.

Handler flow (after existing timer/isRunning cleanup, before uiRef reset):
  1. Resolve vault via Napkin(ctx.cwd). If no vault, bail.
  2. loadVaultConfig to get current distill config (not captured from
     session_start \u2014 cost of one JSON parse at shutdown is negligible).
  3. statSize the current session file.
  4. Call shouldDistillOnShutdown with all assembled inputs.
  5. If predicate true AND vault has .git/ AND sessionFile exists:
     spawnDistillInWorktree({ vault, sessionFile, prompt: DISTILL_PROMPT,
                              model: modelStr }).
     Set lastSpawnedSize = currentSize so any re-entry within the same
     closure is a no-op (currentSize === lastSpawnedSize guard).
  6. Outer try/catch swallows ALL errors and falls through to the final
     uiRef reset. Shutdown MUST NEVER block; failures log to stderr only.

Guards deferred to shouldDistillOnShutdown:
  NAPKIN_DISTILL_NO_RECURSE (recursion inside distill subprocess)
  event.reason === "reload" (session continuing)
  autoDistillSuppressed (per-session /distill-auto-this-session off)
  !config.enabled (master switch)
  config.onShutdown === false (per-vault opt-out)
  !sessionFile (ephemeral session)
  currentSize === 0 (nothing happened)
  currentSize === lastSpawnedSize (interval just grabbed this content)
  currentSize === lastSessionSize (previous distill completed on it)

Additional guards in the handler itself:
  .git/ must exist in vault \u2014 Phase B doesn't auto-init (Phase C will).
  Silent skip rather than UI hint because shutdown has no UI to surface to.

Also: dropped the temporary biome-ignore on `lastSpawnedSize` now that it's
genuinely read in the shutdown handler (was a Phase A breadcrumb).

Tests (+9, 98 \u2192 107):
  - spawns on normal exit with enabled config + git vault + content
  - does NOT spawn when shutdown reason is 'reload'
  - does NOT spawn when config.onShutdown=false
  - does NOT spawn when config.enabled=false
  - does NOT spawn when session file is empty (size === 0)
  - does NOT spawn when vault is not a git repo (needs-git guard)
  - does NOT spawn when NAPKIN_DISTILL_NO_RECURSE is set
  - shutdown never blocks when vault resolution fails (catches throw)
  - sets lastSpawnedSize so a re-entry on same content is a no-op

Tests observe filesystem state (count of `.napkin/distill-worktrees/`
entries) as a proxy for "spawnDistillInWorktree was invoked". The
workspace is created synchronously before detached exec, so there's no
race between the handler return and the assertion.

Phase B exit criteria met:
  - 107 tests pass (85 before Phase B Item 5)
  - pnpm lint clean
  - pnpm install --frozen-lockfile clean
  - 4 new commits (Items 5\u20138), each SSH-signed, Conventional Commits
  - Each commit builds + tests green between

* fix(test): clear NAPKIN_DISTILL_NO_RECURSE in test beforeEach

Tests run inside a distill subprocess (env var set to 1), causing all
'should return true' assertions to fail via guard 1 short-circuit.
Add top-level beforeEach/afterEach to each affected describe block to
clear and restore the env var around each test.

* chore(lint): fix biome formatting in test files

Pre-existing formatting issues from fb51fb5 that biome format surfaced.
No behavior change.

* feat(distill): cleanup stale distill worktrees at session_start

Add cleanupStaleWorktrees() to distill-workspace.ts: scans 'git worktree
list --porcelain', filters to branches under 'distill/', and removes any
worktree whose meta.json is missing, whose pid is dead, or whose mtime is
older than 60 minutes. Best-effort — failures on one worktree don't abort
the sweep.

Wire the helper into session_start's distill-enabled branch, before the
per-session pause state is restored. Runs inside try/catch so any
internal failure never blocks session startup.

Exposes parseWorktreeList, STALE_META_AGE_MS, and a minimal
StaleCleanupVault interface for testability.

Tests (distill-workspace.test.ts): parseWorktreeList covers porcelain
format, detached-HEAD skip, missing trailing newline; cleanupStaleWorktrees
covers live-worktree preservation, dead-pid removal, missing-meta removal,
stale-mtime removal, non-distill-branch preservation, and mixed-state
sweep. 10 new tests (117 total, up from 107).

* feat(distill): auto-init git + scaffold .gitignore/.gitattributes for auto-distill

New module extensions/distill/auto-setup.ts with
ensureVaultReadyForAutoDistill({ contentPath }): SetupResult and a small
countTrackedFiles helper used by the first-run notify.

Contract:
  - Idempotent: re-running on a fully-set-up vault returns
    { initialized: false, scaffolded: [] } with no new commits.
  - Non-destructive: .gitignore / .gitattributes merges only append lines
    not already present; user content is preserved verbatim.
  - Fail-soft: every git / fs failure is translated into SetupResult.error
    instead of throwing, so callers can notify and keep the session alive.

Wiring in index.ts's session_start (distill-enabled branch only, before the
existing cleanupStaleWorktrees sweep):
  - On error: notify with the failure + disable auto-distill for the
    session (in-memory; no persisted state).
  - On fresh init: multi-line notify with tracked file count + undo hint
    (rm -rf .git/) + opt-out hint (distill.enabled: false).
  - On scaffold-only: single-line notify listing the files added.

Tests (auto-setup.test.ts): fresh-vault init+commit, existing-repo scaffold
commit, idempotent second run, .gitignore user-content preservation,
preseeded .gitignore \u2192 only .gitattributes written, partial-setup fill-in,
chmod-based init failure (skipped under root), and countTrackedFiles
coverage. 9 new tests (126 total, up from 117).

* feat(distill): teach auto-distill about supersedes: frontmatter convention

Append a short frontmatter instruction to DISTILL_PROMPT so that when a
distill run creates a note that replaces an older one, it records the
relationship as:
  supersedes: ["path/to/old/note.md"]

A future janitor (not part of this phase) will use that field to archive
superseded notes. Standalone notes leave the field empty or omit it.

No behavior change beyond the prompt text. No test \u2014 prompt-only change.

* refactor(distill): simplify wrapper git add now that .gitignore excludes distill state

Phase B's distill-wrapper.sh used pathspec excludes
(:(exclude).napkin/distill, etc.) on git add -A as belt-and-braces,
because at that point the vault's .gitignore didn't yet cover
auto-distill's ephemeral state. Phase C's auto-setup.ts now installs
those rules into the vault's .gitignore at session_start, so the wrapper
can drop the pathspec form.

Replace the multi-line 'git add -A -- :(exclude)...' with a plain
'git add -A' and update the surrounding comment to point at the new
source of truth.

Test fixture (spawn-distill-in-worktree.test.ts) pre-seeds a minimal
.gitignore that mirrors what auto-setup installs so the integration
wrapper tests reproduce the production invariant. Existing 11 wrapper
tests still pass unchanged.

* fix(distill): auto-setup targets ctx.cwd (matches worktree spawn root)

spawnDistillInWorktree and cleanupStaleWorktrees both operate on
`ctx.cwd` as the git root. 790314e's auto-setup called into napkin's
resolved `contentPath`, which can diverge from `ctx.cwd` on legacy
bare vaults where Napkin resolves `contentPath` to the `.napkin/`
subdir. The mismatch meant auto-setup would `git init` inside
`.napkin/` while worktree code looked for git at the vault root,
silently breaking auto-distill on those layouts.

Realign auto-setup, countTrackedFiles, and cleanupStaleWorktrees calls
to `ctx.cwd`. Update the first-run notify's 'To undo' path so the
command it prints actually matches where the repo got created.

Shutdown-handler test 'does NOT spawn when vault is not a git repo'
becomes 'auto-inits git at session_start' \u2014 Phase C1's auto-init
makes the needs-git guard dead in the happy path. The new assertion
verifies .git was created at the vault root AND a worktree was spawned
(= the full auto-distill chain works end-to-end on a bare vault).

* refactor(distill): extract STALE_WORKTREE_MINUTES constant

Previously the 60-minute stale-worktree threshold was encoded inline as
`60 * 60 * 1000`. Lift to a named `STALE_WORKTREE_MINUTES = 60` so the
value has a single source of truth callable from tests and documented in
one place (the /60 minute/ margin above MAX_DISTILL_DURATION_MS).

STALE_META_AGE_MS is kept as a separate export since existing callers
already import it by that name.

* feat(distill): add /distill-status slash command

Exposes the state of active distill worktrees and unmerged distill
branches as a slash command. Humans can now answer 'what's going on
with auto-distill?' without grepping git worktree list and reading
meta.json by hand.

The core state lives in new helpers on distill-workspace:

- getActiveDistills(vault): scans `git worktree list` for branches
  matching `distill/*`, reads each worktree's meta.json, reports pid
  liveness + elapsed time + start SHA.
- getUnmergedDistillBranches(vault): surfaces distill branches that
  exist as refs but have no active worktree (crashed distill
  breadcrumbs pending gc).

createDistillWorkspace now records the vault's HEAD SHA at
worktree-creation time in meta.json so downstream consumers (the
before_agent_start overlap detector, next commit) can diff exactly
what the distill has written since it forked.

The /distill-status command formats the result as plain text,
routing through ctx.ui.notify when a UI is present and falling back
to console.log in headless mode (pi -p /distill-status ...).

* feat(distill): add napkin_distill_status pi tool for agent visibility

Exposes the same state as /distill-status (active distill worktrees
+ unmerged distill branches) through a pi tool so the LLM can pull-query
before making vault edits.

Tool returns compact JSON:
  {
    active: [{ pid, branch, elapsedSeconds, session, alive, ... }],
    unmerged: ["distill/xyz-456", ...]
  }

Shape matches what the human formatter renders: basename of sessionPath,
floored elapsed seconds, explicit alive flag. Output is small (tens of
tokens in typical cases) and stable \u2014 new keys may be added, existing
keys MUST NOT be renamed or repurposed.

When the agent is outside any napkin vault, the tool returns
{ error: "no vault in cwd" } rather than throwing, so an errant call
doesn't abort the turn.

Serialiser (distillStatusToJson) and formatter (formatDistillStatus)
are exported from index.ts as pure functions and unit-tested directly
(distill-status.test.ts) without needing a full ExtensionAPI mock.

* feat(distill): inject concurrent-distill notice via before_agent_start

When the current pi session has written to vault files that a background
auto-distill is also editing, append a one-line notice to the per-turn
system prompt so the agent knows its recent writes may be clobbered or
merged when the distill completes.

Per-turn and ephemeral: uses before_agent_start's 'systemPrompt' return
rather than 'message' injection, so 0 tokens are spent when there's no
overlap, and nothing is persisted to the session file.

Implementation:

- session-touched-files.ts: reimplements pi's internal
  extractFileOpsFromMessage (dist/core/compaction/utils.js) to walk
  assistant messages and collect paths from 'write' / 'edit' tool calls,
  plus a conservative bash-redirection heuristic (>, >>, tee) for agents
  that shell out for writes. Not exported from pi's public API, so we
  reimplement + pin via version-check test.
- intersectFiles / formatOverlapNotice in index.ts: pure functions,
  unit-tested, handle absolute-vs-relative path mismatches via
  suffix-match.
- before_agent_start handler: env-guarded (skips inside distill
  subprocess), wraps every step in try/catch so failure collapses to 'no
  overlap detected' — must never block the agent turn.
- diffWorktreeSinceStart: drives the distill-side of the intersection.
  Uses the startSha recorded in meta.json (Phase C2+) to diff exactly
  what the distill has committed; falls back to 'git status --porcelain'
  for legacy meta.json files written before startSha was introduced.

Tests: 78 new tests across session-touched-files.test.ts,
session-touched-files.version-check.test.ts, overlap-injection.test.ts,
and diffWorktreeSinceStart tests in distill-workspace.test.ts.

The version-check test asserts pi's internal extractFileOpsFromMessage
still exists at the expected path, so upstream breakage surfaces as a
test failure instead of silent drift.

* docs: invert README/SKILL.md \u2014 README is now the comprehensive doc

Before: SKILL.md was 811 lines of comprehensive napkin CLI reference,
README.md was 75 lines of install + tiny doc stub. Humans found the
README first and got almost nothing; agents found SKILL.md via its
frontmatter description and got too much (the CLI reference mixed with
pi-napkin setup).

After: README.md is the comprehensive doc (249 lines) covering the
whole pi-napkin surface \u2014 install, extensions, auto-distill config,
concurrency (worktree + LLM merge driver + partial-merge salvage),
commands (/distill, /distill-auto-this-session, /distill-status),
agent tool (napkin_distill_status), agent visibility
(before_agent_start overlap injection), vault setup, troubleshooting,
and a pointer to the builder-deleter design.

SKILL.md shrinks to 44 lines: frontmatter (needed for pi skill
discovery), a 2-sentence preamble, a pointer at README, and the vault
resolution section retained verbatim. Vault resolution is the one
piece of operational info that MUST stay at the skill level \u2014
misresolution silently creates a bare vault (data-loss hazard).

For napkin CLI reference, agents now use `napkin --help` /
`napkin <cmd> --help` at runtime. Removing the duplicated reference
from SKILL.md prevents drift between the skill doc and napkin's
actual CLI surface.

Concurrency doc includes the partial-merge salvage behavior per spec
Item 20 ("Document partial-merge behavior in README"): when the LLM
merge driver fails on some files, those files fall back to main's
version while the cleanly-merged files keep distill's content.
Error logs land in <vault>/.napkin/distill/errors/ with the dangling
distill commit SHA for git-gc-grace recovery.

* test(distill): use real pi session_shutdown reason values (G3+C5)

Shutdown-handler tests used 'exit'|'switch'|'error' for reason, but pi's
SessionShutdownEvent.reason union is 'quit'|'reload'|'new'|'resume'|'fork'.
Tests passed by accident (guard 2 only short-circuits on 'reload', other
values fall through) rather than by exercising real upstream contract.

Replace all fabricated reasons with real ones:
- 'exit' -> 'quit' (canonical normal-exit case, 8 call sites)
- widen the runLifecycle type to the full union
- add explicit 'fork' test so the second non-'reload' branch has coverage

Addresses coverage-review G3 and correctness-review C5.

* test(distill): add end-to-end wrapper test with real-driver conflict resolution (G1)

The previous wrapper tests covered happy-path (disjoint files, driver
never fires), concurrent worktrees (disjoint files, sequential), and
3-strike salvage (driver always fails). None exercised the core story:
driver fires during 'git merge main', resolves the conflict, and its
output survives the squash-merge to main.

Add a new describe block with two integration tests:
1. LLM-resolved conflict: driver output lands on main
   - Seeds a baseline, creates worktree, mutates main to force
     divergence, stages distill's divergent version, runs wrapper with
     NAPKIN_DISTILL_MERGE_MOCK=ok so the driver concatenates ours+theirs
   - Asserts the resolved content contains BOTH sides' markers on main
     (proves driver fired AND its output reached main)
   - Asserts no error log entries
2. Driver retries: ok-after-2 succeeds on attempt 3
   - Verifies the 3-strike retry loop bridges transient failures

Addresses coverage-review G1 (BLOCK).

* fix(distill): preserve setup-error safety flag after persistence restore (C1)

The session_start handler set 'autoDistillSuppressed = true' inside the
setup-failure branch (line 316) then ~40 lines later unconditionally
overwrote it with 'readPersistedSuppressed(...)' (line 356). For fresh
sessions the persisted value is 'false', so the force-suppression was
erased before the interval timer armed. Net effect: when 'git init'
fails (readonly FS, permission denied, etc.) the user saw the error
notify AND auto-distill still tried to run on every interval tick.

Fix: track setup outcome in a 'setupFailed' local, and only re-read the
persisted state when setup succeeded. When setup failed, force-suppress
regardless \u2014 the issue is vault-level, not user intent, and the next
session will re-try setup.

Add shutdown-handler test that:
- Simulates setup failure by placing a directory where '.gitignore'
  should be a file (mergeLines hits EISDIR)
- Asserts no shutdown distill spawns
- The previous implementation would have let persisted=false override
  the safety flag and spawned.

Addresses correctness-review C1.

* fix(distill): record wrapper pid in meta.json, not parent pi session pid (C2)

createDistillWorkspace() wrote meta.json before the wrapper had been
spawned, so 'pid' was the parent pi session's pid \u2014 not the wrapper's.
Consequences:
- /distill-status.alive reflected whether the parent pi was still
  running, not whether the distill subprocess was alive. A crashed
  distill showed alive=true as long as the parent lived.
- cleanupStaleWorktrees uses isPidAlive(meta.pid) as its primary
  signal; with a long-lived orchestrator (cr-auto-action spawns many
  sub-distills from one pi) dead distill worktrees could accumulate
  for up to 60 min until the mtime fallback kicked in.

Fix: the wrapper rewrites meta.json's pid field to its own pid ($$)
immediately after installing its cleanup trap, before any work. The JS
side's write becomes a pre-spawn placeholder; within ~100ms of spawn
the wrapper's own pid lands in meta.json. sed+mv gives atomicity on
the same filesystem.

Add NAPKIN_DISTILL_HALT_AFTER_META=1 testing hook so integration tests
can inspect the updated meta.json before the cleanup trap wipes it.

Test: wrapper rewrites meta.json pid to its own pid (C2).
- Creates workspace (meta.pid = process.pid)
- Runs wrapper with HALT_AFTER_META
- Asserts meta.pid is a different positive number, other meta fields
  unchanged.

Update DistillMeta.pid JSDoc to document the pre-spawn-placeholder +
wrapper-rewrite sequence.

Addresses correctness-review C2.

* fix(distill): detect and respect vault default branch instead of hardcoding main (C3)

The wrapper hardcoded 'main' in three places: git merge --no-edit main,
git checkout main -- <f>, and implicitly in the squash step (squashes
into whatever is checked out in VAULT). Two failure modes:
1. Vaults with 'master' as default (older git, init.defaultBranch=master
   in global config): every 'git merge main' fails with 'not something
   we can merge' \u2014 salvage block finds nothing, MERGE_HEAD check passes,
   squash runs against a distill branch never merged with mainline.
2. Main vault with a feature branch checked out at shutdown: squash
   lands distill commits on the user's feature branch, corrupting
   history.

Fix JS side: add detectDefaultBranch(vault) helper using
'git symbolic-ref refs/remotes/origin/HEAD' (conventional), falling back
to current branch via 'git symbolic-ref --short HEAD', then 'main'.
Pass the detected value as the 8th argv to the wrapper (optional,
defaults to 'main' to preserve backward compat for direct wrapper
invocations).

Fix wrapper: accept $8 as DEFAULT_BRANCH, use it for merge + salvage
checkout. Add a defensive HEAD check before squash \u2014 if vault HEAD
isn't on DEFAULT_BRANCH, refuse to squash and log to error dir.

Tests:
- unit: wrapper spawn call has 'main' at argv[8] for a main-default vault.
- integration: detectDefaultBranch returns 'master' for a master-default
  vault; wrapper squash-merges into master when invoked with argv[8]=master.

Addresses correctness-review C3.

* refactor(distill): migrate legacy spawnDistill to argv-based spawn (SEC-1)

The legacy `spawnDistill` built a shell command string and ran it via
spawn('sh', ['-c', cmd]). Today's inputs are safe (constant prompt,
shellEscape applied), but the shape was a regression-magnet: any future
caller that swapped DISTILL_PROMPT for a user-constructed string, or
funneled config.model.id through without shell-escaping, would produce
a shell-injection hazard that's easy to miss in review.

Extract the 'pi ... >/dev/null 2>&1; rm -rf <tmp>' shell body into
scripts/distill-wrapper-legacy.sh and spawn it as
spawn('sh', [script, sessionFile, tmpDir, prompt, model]). Every
variable is a positional argv entry \u2014 no interpolation, no escaping,
no injection surface. Matches the argv style already used by the
worktree spawn path.

Export spawnDistill (was a module-local function) and accept an
optional spawnFn override to support unit testing.

Legacy path remains git-optional; behaviour preserved \u2014 manual
`/distill` in a non-git vault still works.

Tests (spawn-distill-legacy.test.ts):
- No "-c" argv
- positional argv order: [script, sessionFile, tmpDir, prompt, model]
- empty model -> empty string at argv[4]
- detached + stdio:ignore + NAPKIN_DISTILL_NO_RECURSE
- shell-metacharacter values stay isolated as argv, never concatenated

Remove the now-unused shellEscape helper.

Addresses security-review SEC-1.

* test(distill): integration test for before_agent_start overlap injection (G2)

overlap-injection.test.ts only covered the pure helpers
(extractFileOpsFromMessage, intersectFiles, formatOverlapNotice). The
handler wiring (env guard, vault resolution, session walking, active-
distill filtering, diff union, systemPrompt append) had no direct test
coverage \u2014 author comment said 'covered indirectly' but none of these
code paths were exercised.

Add overlap-injection.integration.test.ts with 7 tests:
- overlap exists -> returns {systemPrompt: prefix + notice}
- no overlap -> handler returns undefined
- no active distills -> handler returns undefined
- NAPKIN_DISTILL_NO_RECURSE=1 -> handler short-circuits even with overlap
- dead distill (pid=999999) -> filtered out by alive check
- empty session -> handler returns undefined before touching git
- bogus ctx.cwd -> handler returns undefined, no throw

The 'overlap exists' test uncovered a latent bug: the handler resolved
vaultPath via 'new Napkin(ctx.cwd).vault.contentPath' which points at
'<root>/.napkin' for content-layout vaults. getActiveDistills then
checked '<root>/.napkin/.git' which doesn't exist, so the handler
silently returned no overlap for every real vault. Fix: use ctx.cwd
directly (the git root) for vaultPath; keep the 'new Napkin(ctx.cwd)'
call for its throw-on-unresolvable semantics.

Addresses coverage-review G2.

* test(distill): cover wrapper MERGE_HEAD escape-hatch (G4)

The wrapper's last-line defense at distill-wrapper.sh (the check for
MERGE_HEAD still being present after the driver + salvage) was dead
code in CI. No mock mode emitted conflict markers with exit 0, and when
I tried that approach git actually cleared MERGE_HEAD on driver exit 0
(the driver's output gets committed even if it contains markers).

Add an explicit NAPKIN_DISTILL_FORCE_MERGE_HEAD=1 testing hook that
creates MERGE_HEAD immediately before the escape-hatch check. Also add
a conflict-markers mock mode for completeness (emits exit 0 output
with raw <<<<<<</=======/>>>>>>> markers).

Integration test asserts:
- wrapper exits 1 when MERGE_HEAD is present at the check
- error log contains 'merge did not complete (MERGE_HEAD still present)'
- error log contains the dangling SHA for forensic recovery
- no distill squash commit lands on main

Addresses coverage-review G4.

* fix(distill): quote merge-driver path in git config for space-safety (SEC-2+C4)

registerMergeDriver stored the driver path as part of a shell command
string that git runs via sh -c when a merge fires. Unquoted, any path
containing a space (e.g. '/Users/Foo Bar/...' on macOS display-name
home dirs, or runner-controlled paths in CI) would word-split at the
first space and the driver would silently fail. A malicious path
prefix could also inject shell arguments.

Fix: single-quote the path and escape any literal single-quote inside
it as '\''. Git's sh -c now sees the path as one token.

Tests:
- config readback confirms the driver value starts with ' and ends with
  ' %O %A %B %P
- integration: worktree creation succeeds when the vault path contains
  a space

Addresses correctness-review C4 and security-review SEC-2.

* fix(distill): delimit merge driver inputs to prevent prompt injection (SEC-3)

The LLM merge driver fenced file contents between unfenced pseudo-
markers ('<<<<<<<< BASE', '<<<<<<<< OURS', '<<<<<<<< THEIRS'). Any
file already containing these markers (or a crafted prompt-injection
payload that synthesises them) could terminate the section and speak
to the LLM as the outer prompt author.

Concrete attack path: a note whose body ended with a fake '========'
+ 'OURS' section could instruct the LLM to emit chosen content. Since
auto-distill from one distill becomes input to the next distill's
merge driver, content an earlier agent wrote gets fed back \u2014 a worm
pattern.

Fix: generate per-invocation random 16-hex delimiters for BASE / OURS
/ THEIRS sections. Regenerate if any delimiter accidentally appears
in the inputs (astronomically unlikely). Also add an explicit
instruction at the top of the prompt telling the LLM to treat content
between markers strictly as data and not to follow instructions
inside.

Test (scripts.test.ts):
- Feed inputs that contain the OLD static marker strings. With random
  delimiters the merge still succeeds and output is preserved (not
  replaced by an injected instruction).

Addresses security-review SEC-3.

* refactor(distill): extract common runner from runDistill/runAutoDistill (CLN-1)

runDistill and runAutoDistill were 1:1 duplicates of a ~100-line
polling + bookkeeping sequence, differing only in:
1. Pre-flight (runAutoDistill requires .git, runDistill is git-optional)
2. Spawn call (spawnDistill vs spawnDistillInWorktree)
3. Poll-completion target (tmpDir vs worktree path)
4. Timeout cleanup (fs.rmSync vs cleanupDistillWorkspace)

The Phase B commit had to remember to update both bookkeeping paths;
a future bug fix touching one path risked missing the other.

Extract runDistillWith(ctx, strategy) that owns the shared lifecycle:
config load, size dedup, status-bar painting, poll loop wiring,
completion bookkeeping. The two callers supply a DistillStrategy with:
- preflight?: optional short-circuit check
- spawnFn: returns { target, cleanup } on success, null on failure

runDistill now: 10 lines (strategy setup).
runAutoDistill now: ~35 lines (strategy + DistillError-specific status).

No behaviour change \u2014 all 226 tests still pass. Visible state mutations
(isRunning, lastSessionSize, lastSpawnedSize, lastDistillTimestamp,
pollHandle) remain in the closure, so existing closures-over-state
contracts (shutdown handler, /distill, interval) are preserved.

Addresses security-review CLN-1.

* fix(distill): wrapper distinguishes conflict-remaining from real merge failure (C6)

The wrapper swallowed ALL non-zero exits from 'git merge main' via
'|| true', then let the salvage path decide from the file-level
--diff-filter=U output. This masks real merge failures:
- exit 128 (corrupt index, invalid ref, refusing to overwrite tracked
  files, etc.): the merge never started. salvage finds no unmerged
  files and falls through to squash, which silently lands against a
  distill branch that was never actually synced with mainline.
- Race with concurrent distills: if index.lock contention outlasts
  the retry budget, exit 128 \u2014 same silent corruption window.

Capture the merge exit code explicitly and branch on it:
- 0: clean merge, continue.
- 1: conflicts remain, proceed to salvage (expected).
- other: log 'failed unexpectedly (exit N)' + dangling SHA + exit 1.

Also: drop the git_retry wrapper around this one call. Retrying a
partially-failed merge yields 'you have unmerged files' (exit 128)
on the second attempt, which would now false-positive into the
unexpected-exit branch. Transient index.lock on this single call
isn't a real concern \u2014 we're the sole writer on the distill branch.

Add NAPKIN_DISTILL_FORCE_MERGE_RC=<n> testing hook so tests can
exercise the unexpected-exit branch without contriving a real git
failure of that shape.

Test (C6): force merge_rc=128 via hook, assert wrapper exits 1, error
log contains 'failed unexpectedly (exit 128)' + 'aborting' + dangling
SHA, no squash commit lands.

Addresses correctness-review C6.

* fix(distill): add timeout on merge driver's pi invocation (C7)

The LLM merge driver shelled out to `pi` with no timeout. If the
nested pi hangs (network stall, provider throttle, stuck stdin,
infinite model loop), each attempt blocks indefinitely. 3 attempts per
conflicted file means a single hung file can exhaust the parent
wrapper's 10-min MAX_DISTILL_DURATION_MS budget \u2014 and when that fires,
the worktree is yanked while the pi call is still active.

Fix: wrap the real-path pi invocation in coreutils `timeout`, falling
back to perl's alarm() when timeout isn't installed. Default 60s per
attempt, overridable via NAPKIN_DISTILL_MERGE_TIMEOUT_SECS. If neither
timeout binary nor perl is present (unusual), the call runs unbounded
(acknowledged best-effort).

Test (scripts.test.ts, 3s wall time):
- Place a stub `pi` in PATH that sleeps 30s
- Run driver with NAPKIN_DISTILL_MERGE_TIMEOUT_SECS=1
- Assert driver exits non-zero within 15s (proves timeout fired three
  times before retry budget exhausted, not that stub's sleep
  completed)
- Skip cleanly if neither timeout nor perl is available on the runner.

Addresses correctness-review C7.

* feat(distill): add common secret patterns to auto-scaffold .gitignore (SEC-5)

On a fresh vault with distill.enabled=true, auto-setup runs 'git add .'
on first init. The scaffolded .gitignore covered napkin/Obsidian state
and .DS_Store but nothing else \u2014 a .env, id_rsa, cert.pem, or similar
in the vault root would be permanently captured in the initial commit
(two-week gc grace doesn't help for tracked files). An Obsidian git
plugin or manual push would then expose the secret.

Extend GITIGNORE_LINES with standard secret patterns:
- .env, .env.local, .env.*.local
- *.pem, *.key
- id_rsa, id_ecdsa, id_ed25519
- secrets.json
- .aws/credentials

Tests (auto-setup.test.ts):
- Fresh vault: .gitignore contains all 10 secret patterns.
- Fresh vault with pre-existing .env, id_rsa, cert.pem alongside a note:
  initial commit tracks the note but NOT the secrets.

Addresses security-review SEC-5.

* fix(distill): invoke wrapper via bash + propagate env for CI portability

Two portability fixes so the distill extension works on Ubuntu runners
(and Ubuntu/Debian users in general):

1. Spawn the distill wrappers with `bash` instead of `sh`. The wrappers
   have a `#!/usr/bin/env bash` shebang and use bash-specific syntax
   (arrays, `set -o pipefail`). On Ubuntu, `/bin/sh` is `dash`, which
   parse-errors on bash syntax and exits 2 before any wrapper logic runs.
   This is a latent production bug, not only a test issue.

2. Pass `env: process.env` explicitly in auto-setup's `runGit`. Unlike
   Node, Bun's `spawnSync` does not propagate mutations to process.env
   to the child unless env is passed explicitly — it snapshots env at
   runtime startup. Passing process.env is a no-op on Node and restores
   Node-compat semantics on Bun so tests can control git identity via
   GIT_AUTHOR_* env vars.

3. In shutdown-handler.test.ts, set dummy GIT_AUTHOR_* env vars in
   beforeEach so the Phase C1 test (production does `git init` +
   `git commit` on a fresh vault) succeeds on CI runners without a
   global ~/.gitconfig.

Local users with a global gitconfig are unaffected; this only kicks
in when no `~/.gitconfig` is present (typical of CI runners).

Fixes the 16 failing tests on PR #11 CI:
- 11 distill-wrapper.sh integration tests (dash parse error)
- 4 ensureVaultReadyForAutoDistill tests (env not propagated on Bun)
- 1 session_shutdown Phase C1 test (no identity available)

* chore(deps): migrate from @mariozechner to @earendil-works pi-coding-agent scope

pi-coding-agent and pi-tui were renamed from @mariozechner to
@earendil-works starting with 0.74.0. Migrate all imports, peer
dependency declarations, comments, and version-check test paths to
the new scope. Pin >=0.74.0 to pick up the session_shutdown.reason
field and the SIGTERM handling fix we rely on in shutdown-handler.

- package.json peerDeps: @earendil-works/pi-coding-agent >=0.74.0,
  @earendil-works/pi-tui *
- All TS imports updated across extensions/distill and
  extensions/napkin-context
- session-touched-files.version-check.test.ts path updated
- Comments / JSDoc references updated

Verified: bun test (230 pass), bunx biome check (clean).

* refactor(distill): replace any types with precise UIRef / RunCtx types (CLN-2)

The distill extension stored `ctx.ui` and the shared runner's narrowed
context as `any`, suppressing type-checking at the pi boundary. Replace
both with precise types that track pi's public `ExtensionContext` /
`ExtensionUIContext` surfaces:

- `DistillUIRef.ui` is now `ExtensionUIContext` (gives us setStatus and
  theme type-checked, and any upstream rename surfaces as a compile error
  rather than silently widening).
- `RunCtx` becomes `Pick<ExtensionContext, 'sessionManager' | 'hasUI' |
  'ui' | 'cwd'>` so the four fields we actually read stay in sync with
  pi at the type level.

No behavior change; no test changes. Verified: bun test (230 pass),
bunx biome check (clean).

* refactor(distill): consolidate worktree enumeration to single git call (CLN-3)

`getActiveDistills` and `getUnmergedDistillBranches` both invoked
`git worktree list --porcelain` independently. The /distill-status
handler (collectDistillStatus) calls them back-to-back, so on every
invocation pi ran two identical worktree enumerations.

Introduce `getDistillState(vault)` that does exactly one
`git worktree list --porcelain` + one `git branch --list 'distill/*'`
and returns `{ active, unmerged }` derived from the same snapshot.
Retain the two original functions as thin wrappers that call
`getDistillState` and destructure — keeps the public API intact for
existing tests and downstream callers.

- collectDistillStatus (/distill-status + napkin_distill_status tool)
  migrated to the new call; saves one git invocation per status refresh.
- Added describe block with 4 tests exercising mixed state + wrapper
  agreement.

Verified: bun test (234 pass, +4), bunx biome check (clean).

* test(distill): assert interval-fires-shortly-before-shutdown race captures delta (G5)

Scenario: auto-distill interval ticks, spawns worktree A at size S1,
and records lastSpawnedSize = S1. User types for another second,
session grows to S2 > S1, then quits. Expected: the shutdown handler
spawns a SECOND worktree B to capture the (S1 \u2192 S2) delta \u2014 the
shouldDistillOnShutdown guard 8 lets us through because
currentSize !== lastSpawnedSize.

Add a dedicated describe block with two tests that drive the race
end-to-end:
  - interval fires at S1, content grows to S2>S1, shutdown spawns a
    second worktree (count goes 0 \u2192 1 \u2192 2)
  - interval fires at S1, no new content, shutdown is a no-op
    (count stays at 1)

Mechanism: stub setInterval to capture the auto-distill tick callback
(rather than swallow it like the existing suite), fire it manually,
then drive session_shutdown. Exercises the real runAutoDistill \u2192
lastSpawnedSize wiring instead of relying on the predicate-level
should-distill-on-shutdown.test.ts alone.

Verified: bun test (236 pass, +2), bunx biome check (clean).

* feat(distill): detect conflicting .gitattributes merge rule and refuse to override (G7)

Previously, auto-setup appended '*.md merge=napkin-distill-merge' to
an existing .gitattributes unconditionally. Because gitattributes is
last-match-wins, a user with '*.md merge=union' would end up with our
driver quietly overriding theirs the next time a .md file was merged.

Detect that case and refuse to scaffold:

- `detectConflictingMdMergeRule(gaPath)` (new helper, exported) scans
  for any `*.md` line with a `merge=<driver>` attribute where
  <driver> != napkin-distill-merge. Scope is deliberately narrow:
  exact `*.md` pattern only, no pattern-overlap inference.
- `ensureVaultReadyForAutoDistill` checks this right after git init
  and returns `{ error: 'conflicting merge rule\u2026', conflict: { rule,
  file, driver } }` WITHOUT touching .gitattributes or .gitignore on
  conflict. The existing `setupFailed` path in session_start then
  suppresses auto-distill for the session \u2014 manual /distill still
  works.
- session_start notify explains the options:
    - remove the conflicting rule, OR
    - set distill.onShutdown: false in vault config.json

Test coverage:
- 5 tests in ensureVaultReadyForAutoDistill describe block
  (fresh / idempotent / conflict / narrower pattern / defensive)
- 8 tests in new detectConflictingMdMergeRule describe block
  (missing / empty / comments / our rule / foreign / extras on same
  line / different pattern / no merge=)
- 1 integration test in shutdown-handler.test.ts verifying the full
  session_start \u2192 setupFailed \u2192 shutdown-skips-spawn path, and that
  the user's .gitattributes is preserved byte-for-byte.

Exports:
- New public: `detectConflictingMdMergeRule`,
  `NAPKIN_MERGE_DRIVER` (test convenience).
- `SetupResult.conflict?: { rule, file, driver }` alongside existing
  `error`; both are populated on conflict so fail-soft callers keep
  working.

Verified: bun test (250 pass, +14), bunx biome check (clean).

* test(distill): exercise pollHandle timeout branch with env-var override (G8)

The 10-minute pollHandle timeout in runDistillWith was untested \u2014 a
regression in the timeout path (missed cleanup, stuck isRunning,
wrong status) would only surface after a real 10-minute hang. Add
coverage so the branch is exercised on every CI run.

Mechanism:
- Convert the private `MAX_DISTILL_DURATION_MS` constant into an
  exported `getMaxDistillDurationMs()` that checks
  NAPKIN_DISTILL_MAX_DURATION_MS_OVERRIDE. Production default (10 min)
  is unchanged; malformed / zero / negative overrides fall back to the
  default to prevent accidental instant-timeout in production.
- New test file `pollhandle-timeout.test.ts` stubs setInterval to
  capture both the auto-distill tick AND the pollHandle callback, fires
  the poll callback twice (once before timeout, once after a 150 ms
  real-time sleep past the 100 ms override), and asserts:
  - the timeout branch removes the worktree (via spawnCleanup)
  - isRunning resets so a subsequent auto-tick spawns a fresh distill
  - the production default is 10 minutes when the override is unset
  - malformed / zero / negative overrides fall back to the default

Verified: bun test (254 pass, +4), bunx biome check (clean).

* test(distill): pin pi before_agent_start systemPrompt contract for regression (C8)

The distill overlap injector concatenates onto `event.systemPrompt`
and returns `{ systemPrompt: <concat> }`. Both halves of that
contract live in pi's public types:

  BeforeAgentStartEvent.systemPrompt: string          (we read+concat)
  BeforeAgentStartEventResult.systemPrompt?: string   (we return)

If pi ever changes either field (e.g. structured object, required vs
optional, renamed), our concat would silently truncate or become a
type error without any test in our suite complaining. Add a
companion version-check test modelled after
session-touched-files.version-check.test.ts that:

- verifies the pi types.d.ts is still at the expected path
- extracts the BeforeAgentStartEvent body and asserts
  `systemPrompt: string`
- extracts the BeforeAgentStartEventResult body and asserts
  `systemPrompt?: string`
- asserts pi's docstring still documents chaining (so a rewrite to
  last-wins would be caught)

A failure tells us to inspect the new types, update the overlap
injector if the contract genuinely changed, or resync the assertions
if it was just a rename.

Verified: bun test (258 pass, +4), bunx biome check (clean).

* ci: mirror cad0p/napkin — bun install + semver-calver-release

Migrate CI and local tooling from pnpm to bun to mirror the upstream
cad0p/napkin pattern (bun install + biome + tsc + bun test, OIDC
release via cad0p/semver-calver-release).

Workflows:
- ci.yml: single 'test' job on push/PR to main, runs `bun install`
  then biome check + tsc --noEmit + bun test. Drops the pnpm setup
  and the split lint/test jobs.
- release.yml: auto-release on push to main (and release/* branches
  with skip_release) via cad0p/semver-calver-release/release@v1,
  followed by OIDC npm publish via npm-publish@v1. Replaces the
  softprops/action-gh-release + manual npm publish flow.
- validate-package-version.yml (new): runs
  cad0p/semver-calver-release/validate-package-version@v1 on PRs to
  catch missing / malformed version bumps.
- validate-release-pr.yml (new): runs validate-release-pr@v1 on PRs
  for release-style checks (changelog, etc.).

Local tooling:
- package.json scripts: `biome check` → `bunx biome check`,
  `bun test` unchanged, add `typecheck` script (`bunx tsc --noEmit`).
- Add typescript@^5.8.0 devDep so bunx tsc has a pinned compiler.
- Add publishConfig/repository/homepage/bugs metadata to match
  napkin's shape so semver-calver-release and npm publish have
  everything they need.
- New tsconfig.json mirrors napkin's (strict, esnext, bundler
  moduleResolution, skipLibCheck); excludes *.test.ts because bun
  test types them at runtime.

Lockfiles:
- Switch to bun.lock (committed) for reproducibility. Delete the
  tracked pnpm-lock.yaml and gitignore it (pnpm is no longer used
  for this project).

Content fixes needed to make the new tsc gate green:
- extensions/distill/index.ts: ctx.ui.notify expects
  'info' | 'warning' | 'error'; 'success' was invalid. Change the
  distill-complete notify to 'info'. No visible UX change \u2014 the
  status bar still paints the success glyph via theme.fg.
- extensions/napkin-context/index.ts: pi's CustomMessage.content is
  `string | (TextContent | ImageContent)[]`. Narrow to string for
  the Markdown renderer (we only ever set string content).
- extensions/napkin-context/index.ts: pi's ExtensionContext narrows
  sessionManager to ReadonlySessionManager, which hides
  appendCustomMessageEntry. At runtime it's always the full
  SessionManager; cast through a `SessionManager` type import.

Verified locally:
- bun install (clean)
- bunx biome check extensions/ (clean)
- bunx tsc --noEmit (clean)
- bun test (258 pass, same as before migration)

* fix(distill): match *.md merge rule case-insensitively to honor APFS/NTFS core.ignorecase (R2-1)

The G7 detector's regex used case-sensitive matching, which missed
`*.MD merge=union` rules. On macOS (APFS default) and Windows (NTFS
default) filesystems, git's pattern matching respects
`core.ignorecase=true`, so `*.MD merge=union` DOES apply to `foo.md`
files. Without this fix we silently layer our driver on top via
last-match-wins — exactly the scenario G7 exists to prevent.

- Add `i` flag to the regex in `detectConflictingMdMergeRule`.
- Document the APFS/NTFS rationale in the function docstring.
- Add two new tests: `*.MD merge=union` and mixed-case `*.Md MERGE=Ours`.

Total tests: 258 → 260.

* fix(napkin-context): guard appendCustomMessageEntry cast with runtime check (R2-2)

pi's `ExtensionContext.sessionManager` is typed as
`ReadonlySessionManager` \u2014 a `Pick` that omits mutation methods. The
cast to the full `SessionManager` worked at runtime because pi hands
out the concrete instance, but this is a load-bearing assumption
about pi's internals. If pi ever wraps the session manager in a
readonly proxy, the cast would silently become a `TypeError` at
`appendCustomMessageEntry(...)` call time \u2014 fatal for the extension.

Defense-in-depth:

- Duck-type check via `typeof sm.appendCustomMessageEntry === 'function'`
  before calling (handles the "method simply missing" case).
- `try/catch` around the call (handles the "proxy throws on write"
  case).
- Graceful degradation: on failure, emit a `notify(..., 'warning')`
  and continue without the context injection. Worst-case is a session
  without the vault overview, not a crashed extension.
- `Partial<SessionManager>` cast keeps the type-level surface narrow
  while allowing the runtime probe.

* chore(deps): pin @earendil-works/pi-tui peer dep floor to >=0.74.0 (R2-3)

Previously `@earendil-works/pi-tui` was an unbounded wildcard
(`*`) peer-dep. This allowed a lockfile-less install to resolve
to a pre-migration `@mariozechner` version or an incompatible
future `@earendil-works` major.

Match the floor already set on `@earendil-works/pi-coding-agent`
(`>=0.74.0`) so both peer deps track the same migration boundary.

- `bun install` refreshed `bun.lock`'s recorded constraint.
- No resolved-version changes \u2014 already on `@earendil-works/pi-tui@0.74.x`.

* perf(distill): skip branch listing in getActiveDistills when unmerged not needed (R2-4)

Post-CLN-3, `getActiveDistills` delegated to `getDistillState`, which
unconditionally invoked `git worktree list` AND `git branch --list`.
The sole production caller of `getActiveDistills` (the
`before_agent_start` overlap injector) doesn't need the unmerged
branch list, so the extra git call was wasted work.

Split `getDistillState` into three internal primitives:

- `listDistillWorktrees(vaultPath)` \u2014 `git worktree list --porcelain`
  + distill/* filter.
- `listDistillBranches(vaultPath)` \u2014 `git branch --list`.
- `toActiveDistills(entries)` \u2014 meta.json + pid-liveness hydration
  (no git).

Compose:

- `getActiveDistills` = `listDistillWorktrees` + `toActiveDistills`
  (one git call, no branch listing).
- `getUnmergedDistillBranches` = `listDistillWo…
…emplate placeholders

Item A1 of PR #12 (agent-driven merge architecture). Introduces the
externalised distill prompt as a new artefact pair: `distill-prompt.md`
holds the full agent-driven worktree prompt (10 steps + worktree-isolation
prefix) with four template placeholders ({{worktreePath}}, {{vaultPath}},
{{branchName}}, {{defaultBranch}}); `distill-prompt.ts` exports
`buildDistillPrompt(inputs)` which reads the .md at runtime and
substitutes placeholders.

Why externalise: easier prompt iteration without touching code, markdown
rendering in editors, snapshot-testable against a single source of truth.
Per the PR #12 design's "DISTILL_PROMPT location" lock-in.

Why not yet wired to production callers: at A1 the wrapper still owns
merge/squash/cleanup. Wiring the agent-driven prompt now would have the
agent and the wrapper double-execute steps 7-10, which is worse than
either end. A2 rewrites the wrapper to invoke the agent for those steps;
that is when buildDistillPrompt replaces the inline DISTILL_PROMPT in
index.ts. The .md+loader+test ship at A1 as a freestanding architectural
piece that A2 builds on.

The loader is strict on placeholder coverage and rejects empty inputs —
silent corruption (e.g. `git merge ` with empty branch name) is the
failure mode to avoid, per the methodology guide's never-deferrable
"stale references to renamed concepts" category.

package.json `files` now includes `distill-prompt.md` so the .md ships
in the npm publish (the loader resolves it relative to its own location
via `import.meta.url` + `fileURLToPath`, matching the existing
`scripts-paths.ts` pattern).

Tests: 15 new (363 -> 378 baseline). Cover happy-path substitution,
multi-occurrence replaceAll, empty-input rejection (4), missing-placeholder,
empty-template, and 7 .md content invariants (10 step markers, no-force,
pull-merge, do-not-loop, isolation prefix).
…gent call + hard timeout

Item A2 of PR #12 (agent-driven merge architecture). Rewrites the distill
wrapper from a multi-step pipeline (pi -> add -> commit -> merge -> squash
-> commit) into a single bounded agent call. The agent now owns content
production AND the integration phases (merge, squash, push, cleanup) per
the PR #12 design's "Agent contract" section; the wrapper is a thin
shell: shim install, cd parentCwd, `timeout(1) pi -p $PROMPT`, write
outcome.

Wrapper changes:
  - new positional arg 10: maxDurationSecs (hard agent-task budget;
    default 600s = 10 minutes from `distill.maxDurationMinutes` config,
    no per-phase timeouts anymore — folded into this single knob)
  - replaces git add/commit/merge/squash/commit block (lines 558-680
    of the PR #11 wrapper) with a single `timeout --foreground
    "$MAX_DURATION_SECS" pi --session ... -p "$PROMPT"` invocation
  - drops merge-driver-specific test hooks: NAPKIN_DISTILL_FORCE_MERGE_HEAD,
    NAPKIN_DISTILL_FORCE_MERGE_RC (Phase B will drop the merge driver
    script entirely)
  - drops the partial-merge log file (no driver to 3-strike on)
  - retains: shim install, meta.json pid rewrite, startSha extraction,
    cleanup trap, all HALT_AFTER_* / FORCE_CLEANUP test hooks
  - leaves TODO(A3) markers for post-validation (markers absent, HEAD
    on default, commit count, merged-local detection) and TODO(A4)
    for the salvage path (force-cleanup + failed:<reason> outcome)
  - on agent exit-0: writes `merged-content` unconditionally (placeholder
    until A3); on agent non-zero: exits 1 without writing an outcome
    (placeholder until A4 — JS-side abnormal-termination warning fires)

JS-side changes (extensions/distill/distill-workspace.ts +
extensions/distill/index.ts):
  - SpawnDistillOptions drops `prompt` (built internally now via
    buildDistillPrompt against the .md template introduced in A1) and
    adds `maxDurationSecs` (positive integer, derived from
    getMaxDistillDurationMs(config) / 1000)
  - spawnDistillInWorktree now resolves the agent prompt internally;
    the worktree-isolation prefix is in distill-prompt.md so
    buildWorktreeDistillPrompt is no longer called from the spawn path
    (still exported pending Phase B deletion)
  - 11th wrapper arg added on the JS side to match the new wrapper signature

Test deltas: 363 -> 365 pass, 13 skip, 0 fail.

Skipped tests document the pre-existing driver-specific behavior and are
either replaced in Phase C (mocked-pi fixtures simulating each agent-
behavior class) or deleted in Phase B (driver-specific tests):
  - 4 in (integration): happy-path, empty-distill, POST-CONV-1, concurrent
  - 3 in (partial-merge salvage): clean-file, all-conflict, log-co-locate
  - 2 in (MERGE_HEAD escape-hatch): persists-after-merge, unexpected-rc
  - 2 in (LLM-resolved conflict, end-to-end): driver-output-on-main,
    driver-retries-ok-after-2
  - 1 in (non-main default branch): wrapper-squash-merges-into-master
  - 1 in routing: no-content outcome -> warning notify (resumes after A3
    wires validate_commit_count for no-content detection)

Tests retained and unchanged: shutdown-handler.test.ts (predicate logic),
routing.test.ts spawn-routing tests (worktree vs legacy spawn invariant),
distill-workspace.test.ts (worktree creation, parentCwd validation),
system-prompt.cache-parity.test.ts (POST-R6-CACHE byte-equality),
pollhandle-timeout.test.ts (JS-side poller).

Tests updated to A2 contract: 5 unit tests in
spawn-distill-in-worktree.test.ts (`spawnDistillInWorktree (unit, mocked
spawn)`) — drop `prompt:`, add `maxDurationSecs: 600`, assert new arg [10]
is the maxDurationSecs string and arg [5] (prompt) contains the steps
1-10 markers + substituted placeholders + isolation prefix.

DO NOT YET DELETED (Phase B owns the deletion): the merge driver script
`extensions/distill/scripts/napkin-distill-merge`, the `.gitattributes`
install in auto-setup.ts, scripts.test.ts, and git_retry.sh.
…on default, merged-local detection

Item A3 of PR #12 (agent-driven merge architecture). Replaces the A2
TODO(A3) stub with four post-agent-exit validators that decide the
outcome class deterministically, plus a JS-side dispatch update so the
new classes surface with the right notification severity.

Wrapper helpers added (extensions/distill/scripts/distill-wrapper.sh):
  - validate_no_markers <vault>          — scans tracked *.md files for
    residual conflict markers (`<<<<<<< `, `======= ` exact, `>>>>>>> `
    at line start). Uses `git ls-files -z` so .gitignore'd content
    (`.napkin/distill/`) is skipped automatically. On hit, logs offending
    paths and returns 1.
  - validate_head_on_default <vault> <default> — confirms vault HEAD is
    the symbolic ref `refs/heads/<default>` via
    `git symbolic-ref --short HEAD` (V3-locked: detects detached HEAD
    with a non-zero exit, unlike `branch --show-current` which returns
    empty silently).
  - validate_commit_count <vault> <startSha> — prints the count of
    commits since startSha on the current HEAD; returns non-zero only
    if rev-list itself fails. The wrapper dispatches `no-content` on 0
    and proceeds to the merged-content/local-only dispatch otherwise.
  - detect_local_only <vault> <default> — returns 0 (true) when origin
    is configured AND local is ahead of `origin/<default>`. No origin
    configured means the user never expected a push, so this returns
    false and the outcome stays merged-content.

Wrapper post-agent-exit dispatch (replaces the A2 unconditional
`merged-content` placeholder):
  AGENT_RC == 124 or 137  (timeout SIGTERM or SIGKILL after grace)
                          -> failed:agent-timeout
  AGENT_RC != 0           -> failed:agent-exit-nonzero
  HEAD not on default     -> failed:head-not-on-default
  markers found in vault  -> failed:markers-after-agent-exit
  commit_count == 0       -> no-content
  origin diverges         -> merged-local
  otherwise               -> merged-content

Magic numbers 124 / 137 are coreutils-defined `timeout(1)` exit codes
(SIGTERM exit-within-grace / SIGKILL after grace); named as
TIMEOUT_TERM_RC / TIMEOUT_KILL_RC in the wrapper per the methodology
guide's never-deferrable magic-number rule.

JS-side update (extensions/distill/index.ts formatOutcomeNotification):
  - new `merged-local` case → warning ⚠ "Distillation complete locally;
    not pushed to origin (Ns)"
  - new `failed:<reason>` prefix → error ✗ "Distillation failed: <reason>"
    (the reason code surfaces in both the message and status text so the
    user can diagnose without opening the error log first)

Salvage at A3 is minimal: failure paths write the `failed:<reason>`
outcome and exit 1; the cleanup trap still removes worktree+branch
best-effort. A4 will replace this with the explicit salvage helper that
force-cleans pre-exit and emits a recovery hint into the outcome
sidecar.

Tests: 365 -> 376 pass (+11). New file
extensions/distill/wrapper-validation.test.ts adds 10 end-to-end tests
that drive the wrapper with `NAPKIN_DISTILL_PI_BIN=<bash-stub>`
producing each behavior class:
  - validate_head_on_default PASS / FAIL
  - validate_no_markers PASS / FAIL
  - validate_commit_count PASS / FAIL (no-content)
  - detect_local_only PASS (no origin) / TRIGGERED (merged-local)
  - agent exit non-zero / agent exceeds maxDurationSecs

Plus: routing.test.ts "no-content outcome → warning notify" was skipped
in A2 because the wrapper hadn't yet wired no-content detection. A3
ships that detection, so the test is unskipped here and now passes.

Skipped tests unchanged from A2 (12 driver-specific): partial-merge
salvage, MERGE_HEAD escape-hatch, LLM-resolved conflict, integration
happy/empty/POST-CONV-1/concurrent, non-main default branch
squash-merges. All marked `// will be deleted in Phase B/C`.
… failed:<reason> outcome

Item A4 of PR #12 (agent-driven merge architecture). Replaces the A3
"write outcome + exit 1" failure-path stubs with an explicit `salvage`
helper that V3-locked never touches main vault history.

Wrapper helper added (extensions/distill/scripts/distill-wrapper.sh):

  salvage <vault> <worktree_path> <branch_name> <reason>

  Steps in order:
    1. cd to vault (so `git worktree remove --force` doesn't refuse)
    2. force-remove worktree (`git worktree remove --force`)
    3. rm -rf the leaf if shim residue survives (POST-CONV-3 pattern)
    4. `git worktree prune` for stale entries
    5. `git branch -D` the distill branch
    6. rmdir parent vault-hash dir if empty (POST-CONV-4 pattern)
    7. log a critical-error if vault HEAD isn't on the default branch
       (V3: NEVER `git checkout` on the user's behalf — the salvage
       path is a janitor for the worktree+branch, not a rollback agent
       for main)
    8. write `failed:<reason>` outcome with reason-specific recovery hint

Reason codes (V3-locked):
  markers-after-agent-exit  -> "git revert HEAD --no-edit" + "reflog ~90 days"
  head-not-on-default       -> "git checkout <default>" + "reflog"
  agent-exit-nonzero        -> "see error log" + "reflog"
  agent-timeout             -> "bump distill.maxDurationMinutes" + "reflog"

Outcome sidecar format (multi-line for failed:* classes):
  line 1   = outcome class (machine-readable, drives JS-side dispatch)
  lines 2+ = optional human-readable recovery hint

Happy-path classes (merged-content, merged-local, no-content) stay
single-line so the legacy single-line shape continues to parse cleanly.

JS-side updates:
  - findDistillOutcomeForBranch (distill-workspace.ts) parses line 1 as
    outcomeClass and exposes lines 2+ as `recoveryHint: string | null`.
    Backward-compatible with single-line legacy sidecars (recoveryHint
    is null for those).
  - formatOutcomeNotification (index.ts) renders the recovery hint inline
    in the failed:* notification: "Distillation failed: <reason> — <hint>"
  - DistillStrategy.checkOutcome's return type extended with
    recoveryHint; runDistillWith propagates it through to the dispatch.

Tests: 376 -> 381 pass (+5). New file
extensions/distill/wrapper-salvage.test.ts:
  - markers-after-agent-exit: outcome carries reason + 'git revert' hint;
    main vault HEAD is NOT reset (V3 lockdown — the agent's commit with
    markers stays; user `git revert`s manually per the hint)
  - head-not-on-default: outcome carries reason + 'git checkout main' hint
  - agent-exit-nonzero: outcome carries reason + 'reflog' hint; main
    vault HEAD is unchanged (agent crashed pre-squash)
  - agent-timeout: outcome carries reason + 'maxDurationMinutes' hint
  - happy-path regression: merged-content sidecar stays single-line and
    `recoveryHint` is null

Plus a fix-up to wrapper-validation.test.ts: the A3 helper read the
outcome via `readFileSync.trim()` which now collapses the multi-line
sidecar into one string. Updated to take only line 1, mirroring the
JS-side parser.

Salvage operations are best-effort (worktree-remove / branch-D / rmdir
all swallow errors). The outcome sidecar write is the user-facing
signal; transient cleanup races (concurrent salvage on different distills,
ENOENT on already-removed dirs) don't block it.

V3 lockdown reaffirmed: NO main vault history mutation under any
salvage path. Per case-C analysis in research/v2-v3-verification.md,
`git reset --hard $START_SHA` would silently destroy concurrent user
commits in the autosave-while-editing scenario. The recovery hint
points the user at the forward-only `git revert` path instead.
…-2, CLEAN-A-3, SEC-A-6)

Round 1 spec-blind review caught CLEAN-A-2, CLEAN-A-3, SEC-A-6.

The prompt's opening line told the agent "You are running in an isolated
git worktree at <path>", and step 7 used bare "git merge" / "git add -A"
/ "git commit -m" commands. But pi spawns the agent at PARENT_CWD per
POST-R6-CACHE prompt-cache parity, not the worktree. So bare git in
step 7 would either silently corrupt a parent project repo or fail
with "not a git repository". Steps 8-10 already used "git -C vaultPath"
correctly; step 7 was the inconsistent outlier.

Three changes share one .md, so they share one commit:

CLEAN-A-2 high: rewrite step 7 to use "git -C worktreePath" for every
git operation. Brings step 7's shape into line with steps 8-10.

CLEAN-A-3 medium: replace the false opening with a statement of the
cwd contract. Self-explaining for future readers and the agent itself,
per methodology guide "improve the code, not the comments": the prompt
now describes its actual contract instead of relying on reviewer-side
context to interpret it.

SEC-A-6 medium: add --no-rebase to step 9's pull recovery so a user
with global pull.rebase=true cannot silently bypass the spec's
pull-merge requirement.

Tests: distill-prompt.test.ts gains three assertions; the legacy
"isolated git worktree" string assertion is replaced with the new
contract-shape assertion. spawn-distill-in-worktree.test.ts updates
its prompt-shape assertion to match the new opening.
…t fallback (CLEAN-A-1, SEC-A-3, CI-A-1)

Round 1 cross-reviewer convergence on the timeout(1) invocation:
cleanness-blind, security, and CI-portability lenses all flagged the
same root-cause cluster.

CLEAN-A-1 + SEC-A-3 (high): the wrapper invoked
"timeout --foreground <budget>" without -k/--kill-after. Per
coreutils' man page, without -k, timeout sends a single SIGTERM and
waits indefinitely for the target. A SIGTERM-ignoring agent (a
stuck-in-libc-syscall pi process, a tool subprocess that traps
SIGTERM, an OOM-killing-deadlock scenario) hangs the wrapper forever,
holding the worktree, the gitignored shim, and the .napkin/distill/
cache slot. The header comment claimed SIGKILL escalation happened
after a "10s grace"; that was untrue. The rc-137 (SIGKILL escalation)
arm in the dispatch case was dead code under the bare invocation.

CI-A-1 (high): macOS without "brew install coreutils" doesn't ship
timeout(1) at all; Homebrew installs it as gtimeout by default. The
wrapper's bare "timeout --foreground" expanded to "command not found"
on stock macOS, AGENT_RC=127, routed to failed:agent-exit-nonzero
with no diagnostic naming the missing binary. Every distill on a
stock-macOS user's machine surfaced that misleading outcome.

Fix:

- Detect TIMEOUT_BIN at wrapper start with
  "$(command -v timeout || command -v gtimeout || true)". Fail-fast
  with "exit 2" + actionable error message (mentions
  "brew install coreutils") if neither is on PATH. Solves CI-A-1
  without runtime branching.

- Add TIMEOUT_KILL_GRACE_SECS=30 named constant (per the methodology
  guide's never-deferrable magic-number rule) and pass it via
  -k "${TIMEOUT_KILL_GRACE_SECS}" to the timeout invocation. Now
  SIGKILL escalation actually happens, the rc-137 dispatch arm is
  reachable, and the comment claims match the code.

- Make the grace period overridable via
  NAPKIN_DISTILL_TIMEOUT_KILL_GRACE_SECS so SIGTERM-ignoring stub
  tests can complete within the test timeout budget without waiting
  the full 30s. Documented as a testing hook alongside the existing
  HALT_AFTER_* / FORCE_CLEANUP hooks.

- Replace bare "timeout" with "$TIMEOUT_BIN" in the invocation so the
  detected binary (timeout or gtimeout) is what runs.

Tests:

- New regression: "SIGTERM-ignoring agent → SIGKILL escalates within
  grace, classifies as agent-timeout". Stub does
  "trap '' TERM; sleep 60; touch <marker>". Wrapper runs with
  maxDurationSecs=1 + grace=2. Asserts wrapper exits with
  failed:agent-timeout, the marker was never created (proving
  SIGKILL fired before sleep returned), and elapsed wall-clock is
  under 15s (would hang past 30s outer timeout if -k were dropped).

- The pre-existing "agent exceeds maxDurationSecs" test continues to
  pass — sleep cooperatively exits on SIGTERM at 1s, doesn't reach
  the SIGKILL escalation path. Both paths are now covered.

Verified: bun test green (384 pass, 12 skip, 0 fail; +1 new test),
bunx tsc --noEmit clean, biome check clean.
Round 1 CI-portability lens flagged three workflow-level gaps the four
review lenses (correctness, cleanness, security, CI portability) only
saw because CI was added as a fourth lens after PR #11's missing-git-
identity blind spot. Per the methodology guide's "Known blind spot:
CI/environment portability" section, two of these are never-deferrable
(CI-A-3 unpinned bun version, CI-A-4 missing git identity).

CI-A-2 (high): the matrix ran only ubuntu-latest. Any macOS-only
regression — CI-A-1's missing timeout(1), BSD vs GNU grep flag drift,
BSD xargs -I quirks, bash 3.2 surface — was invisible until a user
reported it. Adds macos-latest to the matrix with fail-fast: false so
one platform's failure doesn't mask the other. Combined with Commit
a2c8c21 (timeout(1) hardening + gtimeout fallback), the macOS slot
now exercises the same wrapper path Linux does. brew install coreutils
runs only when runner.os == macOS so Linux jobs are unchanged.

CI-A-3 (medium → never-deferrable): bun-version: latest floats with
whatever bun release is current on the day CI runs. A bun-internal
regression (test runner, fs API, bun:test semantics) could break CI
without any code change in this repo. Pinned to 1.3.13, the
contributor-machine version. Bump deliberately when a new bun release
ships something useful.

CI-A-4 (medium → never-deferrable): the workflow had no defensive git
identity step. The new wrapper-validation / wrapper-salvage tests do
set identity inside their stubs AND in spawnSync env, but a future
contributor adding a test that calls git commit without all three
layers would silently regress to the PR #11 failure mode. Adds
git config --global user.email/user.name + init.defaultBranch=main
before bun install. Mirrors the recipe captured in PR #11's
deferred.md "Discovered on pi-napkin PR #11 (2026-05-12)" entry.

Verified: bun test green locally (384 pass, 12 skip, 0 fail). YAML
syntax validates via python yaml. The GH workflow itself can only be
verified post-merge against actual runners; the changes are mechanical
and the macOS-slot coverage is the new thing to watch on the next CI
run.
…ty (SEC-A-1)

The locked spec at "Push behavior: never force" mandates wrapper-side
post-validation:

  "Spec the prohibition in the prompt; the wrapper post-validates by
   checking that origin/<default> only fast-forwarded (its tip is an
   ancestor of the new tip after push)."

Round 1 security review caught that the wrapper implemented only the
first half. The prompt at distill-prompt.md:45 says "NEVER use --force
or --force-with-lease", but detect_local_only's SHA-equality check
couldn't distinguish a fast-forward push from a force-push: in either
case after the agent's push fetches back, local == origin/<default>.
An agent that ignored the prompt prohibition (model error or prompt-
injection from vault content) would land corrupt origin history with
the wrapper writing merged-content checkmark to the user.

The prompt is a soft control. The wrapper's post-validation is the
hard one. Today, the hard one didn't exist.

Fix:

- Replace the rc=0/rc=1 binary in detect_local_only with three states:
    rc=0 — local-only (merged-local): legitimate fast-forward state
           (origin configured AND remote is ancestor of local) OR
           origin configured but origin/<default> never fetched.
    rc=1 — in sync OR no origin: proceed to merged-content.
    rc=2 — divergent histories: remote is NOT an ancestor of local.
           Force-push or remote rewrite happened.
  The ancestry check is `git -C <vault> merge-base --is-ancestor
  <remote_sha> <local_sha>`. Returns 0 when remote is ancestor of
  local — exactly the condition the spec asks for, just inverted to
  detect violations.

- Update the dispatch site to capture rc explicitly (because bash
  `if cmd; then` collapses rc=1 and rc=2 into the same arm) and route
  rc=2 to a new failed:force-push-detected outcome class via the
  salvage helper.

- Add a recovery hint for force-push-detected: points the user at
  `git log origin/<default>..<default>` and `git log
  <default>..origin/<default>` to inspect the divergence, plus reflog
  for distill content recovery.

- Update the salvage() docstring and the dispatch-site comment to
  list force-push-detected alongside the four existing reason codes.

- Update extensions/distill/index.ts's formatOutcomeNotification
  comment to mention the new reason code (the dispatch logic itself
  is generic over failed:<reason> and needed no code change).

Tests:

- New regression: "detect_local_only DIVERGENT: origin rewritten
  under local → failed:force-push-detected (SEC-A-1)". Builds a
  divergent state by giving origin a foreign commit (via a second
  clone) and committing a different file on the vault's local main
  before the agent runs. Asserts wrapper exits 1 with
  failed:force-push-detected.

- The existing detect_local_only TRIGGERED test (origin behind local
  in a fast-forwardable way) continues to pass — that's now the
  rc=0/merged-local arm of the new tri-state semantics, exercised
  exactly the way the spec describes legitimate "didn't push" state.

Verified: bun test green (385 pass, 12 skip, 0 fail; +1 new test),
bunx tsc --noEmit clean, biome check clean.
…trap (SEC-A-2)

Round 1 security review caught defense-in-depth gap. Today's salvage()
and the cleanup trap call:

    if [ -d "$worktree" ]; then
      rm -rf "$worktree" 2>/dev/null || true
    fi

after `git worktree remove --force`. Note: `git worktree remove --force
<path>` REFUSES paths not registered as worktrees of the vault (returns
non-zero, suppressed by `|| true`). The directory then still exists,
the second `[ -d ]` is true, and `rm -rf` runs on whatever path was
passed in.

JS-side `resolveCacheRoot` always builds worktrees under
`<XDG_CACHE_HOME or ~/.cache>/napkin-distill/<vault-hash>/<branch-suffix>/`,
so under normal use the wrapper is safe. But "JS-side construction is
correct" is the only line of defense. An upstream bug (out-of-tree
caller, malformed test fixture, future code path) that passed
`worktree=/etc` or `worktree=$HOME` would silently turn into
`rm -rf /etc` or `rm -rf $HOME`. Per the methodology guide's "Severity
ladder → high" entry, defense-in-depth gaps that could cause silent
data loss are high, not medium — JS-side correctness is the primary
defense, this is the safety net.

Fix:

- Add `safe_rm_worktree` helper. Resolves the input via `cd <path>
  && pwd -P` (canonicalises symlinks portably across BSD/GNU; defeats
  symlink-tricks). Refuses to `rm -rf` unless the resolved path
  matches `*/napkin-distill/*/*` (i.e. lives at least two levels
  deep under a `napkin-distill` directory — matching the production
  cache layout's `<cache>/napkin-distill/<hash>/<suffix>/` shape).
  On refusal, logs a precise diagnostic naming both the input and
  the resolved path and returns 1; caller proceeds (best-effort
  cleanup is the contract).

- Replace `rm -rf "$worktree"` in salvage() with
  `safe_rm_worktree "$worktree" || true`.

- Replace `rm -rf "$WORKTREE"` in the EXIT trap (cleanup function)
  with `safe_rm_worktree "$WORKTREE" || true`.

Tests:

- New describe block `safe_rm_worktree path-safety guard (PR #12
  SEC-A-2)` with four assertions:
    - refuses to rm-rf a path outside any napkin-distill subtree
      (asserts the directory and its sentinel file survive, and the
      stderr names "refusing to rm-rf" + "napkin-distill")
    - removes a path inside a `napkin-distill/<hash>/<suffix>/`
      subtree (asserts the directory is gone)
    - refuses an empty path (rc 1, stderr names "empty worktree path")
    - returns 0 for a non-existent path (idempotent / already-removed)

- The function is extracted from the wrapper via awk-style range
  scan (start at `safe_rm_worktree() {`, end at the next bare `}`)
  and sourced into a tiny bash harness with a stub log_error so the
  test can call the function with crafted paths the production
  wrapper would never construct.

- All five existing salvage scenarios (markers, head-not-on-default,
  agent-exit-nonzero, agent-timeout, happy-path) continue to pass —
  they hit the `*/napkin-distill/*/*` arm of the new guard and behave
  identically to the pre-fix code.

Verified: bun test green (389 pass, 12 skip, 0 fail; +4 new tests),
bunx tsc --noEmit clean, biome check clean.
The pre-fix write_outcome used two printf calls inside a single redirect group. Bash flushes between the two printfs, so a JS-side poller (findDistillOutcomeForBranch) could open the file between the kernel writes of line 1 and line 2, see only the outcome class, and misclassify a failed distill as having no recovery hint.

Fix: write to a sidecar.tmp file then mv to the canonical path. POSIX rename(2) is atomic on the same filesystem, so the poller either sees the file absent or sees the complete contents, never a partial write. Standard temp-and-rename pattern.

Tests: extract write_outcome from the wrapper into a bash harness (same pattern as the SEC-A-2 safe_rm_worktree extraction) and assert single-line and multi-line outcomes write the exact expected bytes, no .tmp straggler is left behind after the call, idempotent rewrite replaces the file cleanly. Plus an end-to-end test that spawns the real wrapper and asserts the post-run error dir contains no .tmp files.

Cites SEC-A-4 (medium).
…buildDistillPrompt inputs (SEC-A-5)

Defense-in-depth at the API contract boundary: validateInput() now rejects (a) non-string / empty, (b) ASCII control chars in [\x00-\x1F] + \x7F, and (c) {{ / }} placeholder-syntax collisions for every field of DistillPromptInputs.

Rationale: a literal newline in any input field would break out of the prompt step it embeds in (classic prompt-injection class). NUL bytes truncate strings on most C-side consumers. Mustache-collision is a future-refactor hazard if the substitution loop ever multi-passes.

Realistic inputs (paths with spaces / hyphens / dots, branch names with slashes like 'feat/foo' or 'release/2026-q1') still pass.

Tests: 42 new in distill-prompt.test.ts cover each control-char representative (NUL, LF, CR, TAB, BEL, ESC, DEL) across all four input fields, three placeholder-syntax shapes across all four fields, and two regression-guard happy-path inputs.

References: SEC-A-5 (medium) per Phase A round-1 security review.
… types co-present (CORR-A-1, SEC-A-7)

Real merge conflicts always emit ALL THREE marker types ('<<<<<<< ', '======= ', '>>>>>>> ') in the same file. The prior validator flagged any one of them, false-positiving on two common shapes: (1) markdown setext H1 underlines ('# title' followed by '=======' on its own line), and (2) documentation prose that quotes a single conflict marker to discuss merges (e.g. README sections that walk the reader through what '<<<<<<< HEAD' means). Either shape would have permanently blocked distills on the affected vault.

Tighten the per-file predicate to require all three markers co-present in the same file via a 'grep -q && grep -q && grep -q' chain. Use a bash 3.2 portable 'while IFS= read -r -d "" file' loop with array '+=' aggregation so the helper still runs on macOS default bash. Continues to enumerate via 'git ls-files -z -- *.md' so .gitignore'd content (e.g. '.napkin/distill/') is skipped.

Tradeoff: a vault that documents a complete '<<<<<<<' / '=======' / '>>>>>>>' example inside a single file (including in a fenced code block — the validator does not parse markdown structure) will trip the check. Users escape with leading whitespace, HTML comments, or by splitting the example across two files. The cost of any-of-three false-positives (block-all-distills-forever on legitimate documentation) is much higher than this rare-explicit-doc case.

Tests: five regression guards in wrapper-validation.test.ts pin the new behavior — setext H1 underline passes, single-marker prose passes, two-marker prose passes, markers split across two files passes, and all-three inside a code block fails (acknowledged tradeoff). Existing all-three real-conflict FAIL test still passes.

References: CORR-A-1 (low→elevated), SEC-A-7 (low→elevated). Elevation rationale: false-positives would permanently block distills on user vaults documenting merge conflicts in notes. CLEAN-A-15 tracks the per-file grep -q optimisation opportunity.
…s (CLEAN-A-6)

wrapper-validation.test.ts and wrapper-salvage.test.ts duplicated ~80 LOC of identical scaffolding (Scaffold interface, makeScaffold, writeStubPi, runWrapper). Move them to _test-helpers.ts as makeWrapperScaffold(prefix), writePiStub, runWrapperWithStub plus the WrapperScaffold interface, and rename the call sites in both files via local aliases so the test bodies stay byte-identical.

runWrapperWithStub returns the union of both prior runWrappers' outputs (outcome/outcomePath from the validation variant + preSha from the salvage variant). Both consumers selectively read what they need; the extra computation is cheap (one rev-parse + one readdir scan).

Side-effect on tsconfig: extensions/**/_test-helpers.ts is now in the tsc exclude list alongside *.test.ts. The previously-included withNapkinOnPath helper passed strict checks because it never touched SessionManager; the SessionManager.create + appendMessage incantation moved here is the same loose pattern every test file uses (already excluded). package.json files already excluded _test-helpers.ts from the published bundle, so this aligns the two configs.

Pure refactor — no behavior change. All 30 wrapper-{validation,salvage} tests still pass; full suite unchanged at 440 pass / 12 skip / 0 fail. Phase C will benefit when adding bash-stub fixtures for ~10 mocked-pi behaviors.

Cites: CLEAN-A-6 (medium, never-deferrable cleanness).
…EAN-A-4, CI-A-6)

CLEAN-A-4: distill-wrapper.sh's docstring still carried 'A2 transitional state', 'TODO(A3)', and 'TODO(A4)' markers from the staged-rollout commits. A3 (afed6ae) and A4 (0d0a262) both landed weeks ago, so the markers misrepresent the wrapper's actual state. Replace the multi-paragraph A2/A3 evolution block with a one-line implementation-history reference, drop 'stubbed in this A3 commit' parentheticals from the lifecycle steps, and trim the TODO(A3)/TODO(A4) markers from the agent-responsibilities footer. The numbered Lifecycle section already documents the current shape correctly.

CI-A-6: two describe.skip blocks in spawn-distill-in-worktree.test.ts (MERGE_HEAD escape-hatch at L1440, LLM-resolved conflict end-to-end at L1650) lacked the '// will be deleted in Phase B' annotation that the parallel partial-merge-salvage block at L1126 carries. Both target the pre-A2 NAPKIN_DISTILL_MERGE_MOCK driver path that A2 deleted, so they share Phase B fate. Add the same annotation form (suffix on the closing comment-block line) for grep parity.

Comment-only changes. bash -n on the wrapper still parses; full test suite unchanged at 440 pass / 12 skip / 0 fail; tsc + lint clean.

Cites: CLEAN-A-4 (medium, never-deferrable: stale comments), CI-A-6 (medium, never-deferrable: stale references). Both never-deferrable per release-grade methodology.
cad0p added 28 commits May 15, 2026 21:00
…-setup install (B1)

PR #12 Phase B item B1 — remove the now-dead per-file LLM merge driver and stop installing it on new vaults. The agent-driven merge architecture (Phase A) made this code unreachable: the distill agent now resolves merges itself in its worktree (steps 7-10 of distill-prompt.md), so there is no driver to register, no .gitattributes rule to install, and no scripts.test.ts to maintain.

Deleted: extensions/distill/scripts/napkin-distill-merge (455 LOC bash driver), extensions/distill/scripts.test.ts (driver test suite — would fail at module-load once MERGE_DRIVER_SCRIPT export is removed; pulled forward from B2 to keep tests green between commits per methodology), MERGE_DRIVER_SCRIPT export from scripts-paths.ts, registerMergeDriver() helper + its call site in distill-workspace.ts, GITATTRIBUTES_LINES + NAPKIN_MERGE_DRIVER + detectConflictingMdMergeRule + SetupResult.conflict from auto-setup.ts, the G7 conflict-dispatch arm + comment in index.ts.

Test cleanup: remove pre-PR-12 '*.md merge=napkin-distill-merge' fixture writes from routing.test.ts, pollhandle-timeout.test.ts, shutdown-handler.test.ts, spawn-distill-in-worktree.test.ts (lines were inert no-ops post-driver-deletion). Drop the G7 'session_shutdown handler — conflicting .gitattributes blocks setup' integration test from shutdown-handler.test.ts (the conflict path it pinned no longer exists). Drop the two merge-driver-specific tests from distill-workspace.test.ts (shell-quoting + space-in-vault-path). Rewrite auto-setup.test.ts to drop GITATTRIBUTES_LINES / NAPKIN_MERGE_DRIVER / G7 conflict-detection coverage and update the 'fresh vault' / 'partial-setup' / FB-2 tests so .gitignore is the only scaffolded file.

Migration policy (locked per design.md 'Migration', user direction 2026-05-15): manual cleanup, no automatic removal. Existing vault .gitattributes files retain the now-orphaned '*.md merge=napkin-distill-merge' line — git falls back to its built-in merge driver once the script is gone, so the line becomes inert, not harmful. Auto-setup change removes only the install path; no remove-old-rule path is added. README will document manual cleanup steps in Phase D (D1).

Net delta: -1960 / +58 LOC. Tests: 423 pass / 12 skip / 0 fail (down 31 tests vs Phase A tip 454: -25 from scripts.test.ts deletion, -3 from distill-workspace.test.ts merge-driver tests, -1 from shutdown-handler.test.ts G7, -2 net from auto-setup.test.ts G7 + GITATTRIBUTES rewrites). bunx tsc --noEmit clean. bun run lint clean.

Refs: design.md 'PR scope table > Phase B > B1' / 'What gets deleted' / 'Migration'. Closes CLEAN-A-8, CLEAN-5 (R3) from deferred.md.
PR #12 Phase B item B2 — remove tests that exercised the now-deleted napkin-distill-merge driver and the wrapper salvage / merge-driver code paths that B3 will trim. With the agent owning merge resolution end-to-end, these tests pin behaviors that no longer exist.

Deleted from extensions/distill/spawn-distill-in-worktree.test.ts: the 'distill-wrapper.sh (partial-merge salvage)' describe.skip block (3 tests, NAPKIN_DISTILL_MERGE_MOCK=fail-driven salvage flow), the 'distill-wrapper.sh (MERGE_HEAD escape-hatch)' describe.skip block (2 tests, FORCE_MERGE_HEAD/FORCE_MERGE_RC testing-hook coverage), the 'distill-wrapper.sh (LLM-resolved conflict, end-to-end)' describe.skip block (2 tests, NAPKIN_DISTILL_MERGE_MOCK=ok happy-path coverage), and the four pending-Phase-C 'happy path' / 'empty distill' / 'POST-CONV-1' / 'concurrent worktrees' test.skip entries plus their now-orphaned helpers (runWrapper, createWorkspaceWithChanges) inside the integration describe. Also dropped the master-default-branch test.skip (Phase B/C-annotated). Phase C will replace the four happy-path / no-content / pi-self-commit / concurrent slots with bash-stub mocked-pi fixtures that exercise the agent's behavior space.

Side-effect cleanup: 'distill-wrapper.sh (non-main default branch)' describe block lost its only consumer of sessionFile (the deleted master-default test.skip), so the let-binding was removed; the createSeededSessionFile call is retained for its side-effect of seeding the SessionManager disk fixture.

scripts.test.ts deletion was pulled forward into B1 to keep tests green between commits (it imports MERGE_DRIVER_SCRIPT, which B1 removed). FORCE_MERGE_HEAD/FORCE_MERGE_RC search came up empty in wrapper-validation.test.ts — those were already absent.

Net delta: -1145 / +44 LOC. Tests: 411 pass / 0 skip / 0 fail (down from 423 pass / 12 skip after B1; -12 from the test.skip / describe.skip deletions). bunx tsc --noEmit clean. bun run lint clean.

Refs: design.md 'PR scope table > Phase B > B2' / 'What gets deleted'.
…paths (B3)

PR #12 Phase B item B3 — remove the residual merge-driver-specific code paths in the wrapper and the JS-side dispatch arm. With B1 + B2 done, no caller invokes any of these paths; this commit deletes them.

Wrapper (extensions/distill/scripts/distill-wrapper.sh): drop the 'source git_retry.sh' line and the HERE script-dir resolution that only existed to load it (CI-A-1 R1, CI-1 R2, CORR-8 R3); refresh the 'single fatal-error log per branch' comment to drop the now-stale 'PR #12 removes the partial-merge log' wording. NAPKIN_DISTILL_MERGE_TIMEOUT_SECS / NAPKIN_DISTILL_MERGE_MOCK / MERGE_HEAD escape-hatch / partial-merge log emission / salvage-via-checkout-main paths were already absent from the post-Phase-A wrapper (Phase A's A2 rewrite never carried them forward; only the source-git_retry stub remained, hence single-line wrapper change here).

Helper script: delete extensions/distill/scripts/git_retry.sh (65 LOC) entirely — no callers remain after the wrapper's source line is gone. Also drop the GIT_RETRY_SCRIPT export from extensions/distill/scripts-paths.ts (only referenced by the deleted scripts.test.ts in B1).

JS-side cleanup (CLEAN-2 R3 / CORR-7 R2 / CORR-10 R3 — partial-merge JS-arm cleanup): drop the partialMergeLogPath field from the DistillOutcome interface in distill-workspace.ts, drop the partial-merge-specific parser branch in findDistillOutcomeForBranch (the .partial-merge.log path lookup), drop the 'partial-merge' switch arm + readPartialMergeLog plumbing from formatOutcomeNotification in index.ts, and update its docstring/severity-contract comment in index.ts and routing.test.ts to drop the partial-merge row. The merged-local row is now documented alongside merged-content / no-content / failed:<reason>.

Test updates: drop the four partial-merge tests + the partialMergeLogPath:null fixture cruft in index.test.ts (formatOutcomeNotification suite). Drop the two partial-merge tests in error-log-surfacing.test.ts (findDistillOutcomeForBranch suite); rename the surviving 'returns class + path, no partial-merge log' tests to 'returns class + path' since the field is gone. Retain the two R8-CC-1 .partial-merge.log filter tests (findDistillErrorLogForBranch / assertNoWrapperFailures) — they pin the defensive filter that excludes orphaned .partial-merge.log files left over from PR #11 vaults.

What's NOT deleted (deliberately): the auto-setup.ts doc-comment + .gitignore-only-scaffold comment that explain the migration policy ('PR #12 deleted that driver — the distill agent now resolves merges itself in its worktree...'). These are user-facing migration notes that Phase D's README will mirror; deleting them would lose context. The 'napkin-distill-merge' string only appears as policy explanation, not as a live identifier.

Net delta: -249 / +24 LOC. Tests: 405 pass / 0 skip / 0 fail (down from 411 after B2; -6 from the merge-driver dispatch / partialMergeLogPath test deletions). bunx tsc --noEmit clean. bun run lint clean.

Refs: design.md 'PR scope table > Phase B > B3' / 'What gets deleted'. Closes CLEAN-A-10, CLEAN-2 (R3), CORR-7 (R2), CORR-10 (R3), CI-A-1, CI-1 (R2), CORR-8 (R3) from deferred.md.
…driver (CORR-1, CLEAN-1, CLEAN-2, CLEAN-3, CORR-2)

Phase B Round 1 surfaced 4 medium-severity stale-prose findings across 3 reviewers (cross-reviewer consensus). Per methodology never-deferrable category (stale documentation), comment refresh is mandatory in this PR.

Refreshed 5 doc-comment sites in production code: distill-workspace.ts:712 (wrapper-failure-log description), index.ts:996 (forensic log description), index.ts:1125 (runAutoDistill JSDoc), index.ts:1179 (worktreeSpawnFn JSDoc), auto-setup.ts:351 (seedless-init corner-case comment).

Each refresh removes references to the deleted merge driver / merge-driver 3-strike / .gitattributes routing and replaces with the agent-driven flow per design.md "Locked decisions". Comment-only changes; behavior unchanged.

Authored by Phase B Pass 1 fixer subagent (terminated mid-commit at 7.1m). Orchestrator persisted the pre-authored changes via git add + commit (state management, not authoring) per methodology's "final one-line polish" allowance.
…ixture (CLEAN-4)

These env vars were consumed by git_retry.sh which Phase B deleted (B3, fac0cb1). They became inert no-ops in this test fixture.

Removed lines 819-820 of spawn-distill-in-worktree.test.ts. Test still passes — the env vars had no observable effect post-deletion.

References: CLEAN-4 (B-R1) per Phase B Round 1 cleanness review.
Adds extensions/distill/test-fixtures/agent-stubs/ with 10 executable bash scripts simulating each behavior class from the design's Mocked-pi behaviors testing plan: clean-distill, conflict-resolve-clean, conflict-leave-markers, no-distill, squash-skipped, multiple-commits-on-main, pushed-success, push-fail-merged-local, agent-timeout, agent-crashes.

Each fixture is self-contained, reads NAPKIN_STUB_* env vars from the test harness for vault/worktree/branch paths, and produces deterministic filesystem effects (commits, marker files, etc.) so the wrapper's full pipeline (post-validation, salvage, outcome dispatch) can be exercised without burning real LLM tokens.

Adds an exclusion to package.json files so the test-fixtures dir stays out of the published bundle (verified via bun pack --dry-run). Phase A's existing inline writePiStub patterns in wrapper-validation.test.ts and wrapper-salvage.test.ts are left in place; this commit only adds the formal fixture surface. C2 wires tests against these fixtures.

Per design spec PR scope table > Phase C > C1.
Adds extensions/distill/agent-driven-merge.test.ts with 10 integration tests, one per Mocked-pi behavior class, driving the wrapper end-to-end against the formal fixtures from C1. Each test points NAPKIN_DISTILL_PI_BIN at a fixture file and asserts the resulting outcome class + sidecar contents.

Extends runWrapperWithStub in _test-helpers.ts with an opts.fixturePath knob that bypasses the inline writePiStub step and auto-injects NAPKIN_STUB_VAULT/_WORKTREE/_BRANCH/_DEFAULT_BRANCH env vars derived from the workspace, so fixtures can reach the test scaffold without JS-side template-string interpolation. Inline-stub callers ignore these env vars; behavior unchanged for existing tests.

Coverage relationship to existing tests: wrapper-validation.test.ts and wrapper-salvage.test.ts already cover most validators/salvage paths in isolation. This file adds a higher-level integration view + fills four genuine gaps that inline-stub tests don't reach: (a) conflict-resolve-clean — actual git-merge-with-conflict + agent resolves + squashes, (b) squash-skipped — agent commits to worktree distill branch but never squashes, (c) multiple-commits-on-main — wrapper accepts >=1 commits per validate_commit_count contract, (d) pushed-success — origin configured + agent pushes successfully (complementing the existing merged-local test that covers the no-push case).

Test count: +10 (405 → 415). Per design spec PR scope table > Phase C > C2.
Adds a toMatchSnapshot regression guard over buildDistillPrompt(SAMPLE_INPUTS) to catch unintended drift in the rendered prompt prose. Existing tests in distill-prompt.test.ts assert specific substrings (10 numbered step markers, force-push prohibition, pull-merge-not-rebase, worktree-isolation prefix); the snapshot pins the FULL document at canonical sample inputs so a copy-edit that softens a directive (e.g. "never use --force" → "avoid --force") is caught even if the named substrings still match.

Snapshot file lands at extensions/distill/__snapshots__/distill-prompt.test.ts.snap (Bun's default location, sibling to the test file). The snapshot is checked into git; intentional prompt edits will require regenerating with bun test -u and the diff is reviewable per commit.

Verified the snapshot file is excluded from the published bundle via bun pack --dry-run (no extensions/**/__snapshots__/** match in package.json files).

Test count: +1 (415 → 416). Per design spec PR scope table > Phase C > C3.
Audit of wrapper-validation.test.ts and wrapper-salvage.test.ts post Phase A+B identified two genuine gaps:

(1) validate_head_on_default has TWO distinct failure paths (HEAD on a different branch vs HEAD detached). Existing tests covered the feature-branch case via 'git checkout -b'; the detached-HEAD case (which routes the same failed:head-not-on-default outcome class but emits a detached-specific salvage log line) was untested. The new test pins the detached-HEAD diagnostic so a future refactor can't collapse the two paths and silently regress the diagnostic.

(2) Multi-validator dispatch ordering — when the agent leaves the vault in multiple invalid states simultaneously (e.g. both wrong HEAD AND markers), the wrapper's documented dispatch order is: head_on_default → no_markers → commit_count → local_only. Earliest validator wins. The new test pins head-vs-markers ordering: an agent that commits a marker-bearing file AND switches off the default branch must report failed:head-not-on-default, not failed:markers-after-agent-exit. This matters for forensic clarity — the outcome class drives the recovery hint, so a misordered dispatch would point users at the wrong recovery action.

Other validators (validate_no_markers, validate_commit_count, detect_local_only, safe_rm_worktree) were verified comprehensively covered by existing tests in wrapper-validation.test.ts and wrapper-salvage.test.ts; the seven failed:* reason codes (markers-after-agent-exit, pre-existing-markers, internal-validator-error, head-not-on-default, agent-exit-nonzero, agent-timeout, divergent-history) all have recovery-hint assertions in either suite. The internal-validator-error reason code (Phase A Pass 3) is also covered (wrapper-validation.test.ts:511).

Test count: +2 (416 → 418). Per design spec PR scope table > Phase C > C4.
…very flow (PR #12 C-R1 CORR-1)

design.md "Mocked-pi behaviors" #8 calls out the recovery branch

where the agent's first push fails (origin advanced during distill) and

the agent recovers via 'git pull --no-rebase' + push. Phase C's

push-fail-merged-local.sh covers the OTHER valid path (agent gives up

on push, outcome merged-local) but the recovery-succeeds path was

uncovered — the --no-rebase invariant in the prompt was not

exercised end-to-end.

New fixture push-fail-pull-merge-success.sh:

  a. agent commits on the worktree distill branch

  b. squashes onto vault default

  c. first 'git push' — fails non-ff (test pre-arranges origin advance)

  d. 'git pull --no-rebase origin <default>' folds origin's commit in

  e. second push — succeeds

The fixture writes a sentinel file recording the exact pull command

it ran so the test can assert --no-rebase was used (the design's

never-rebase-main invariant; users with pull.rebase=true globally

would otherwise rewrite local main silently).

New helper advanceOriginFromSideClone in agent-driven-merge.test.ts

clones origin to a side directory, commits, pushes back, simulating

a teammate landing a commit during the distill window.

New test pins:

  - outcome merged-content (recovery succeeded; not merged-local)

  - localSha === originSha (final push landed)

  - sideSha is ancestor of local main (merge actually folded it)

  - sentinel contains 'pull --no-rebase origin main' (--no-rebase pinned)

  - distilled file 'pulled-merged.md' is on default

push-fail-merged-local.sh is preserved (different valid scenario).

Findings addressed: CORR-1 (Phase C Round 1, high).

Tests: 418 -> 419 (1 new).
…to default (PR #12 C-R1 CORR-2)

design.md "Mocked-pi behaviors" #6 specifies that when the agent

commits 2+ times directly to the default branch (rather than commit

to its branch + squash-merge to default), the wrapper:

  - ACCEPTS the outcome as merged-content (the dispatch is

    no-content vs has-content; the squash invariant is a soft

    suggestion in the prompt, not a hard wrapper constraint), AND

  - LOGS A WARNING so the forensic record reflects the violation.

The wrapper currently does (a) but not (b). Add (b).

Critical design choice: warnings live in a SEPARATE

<base>.warning.log file, NOT the fatal .log file. The JS-side

findDistillErrorLogForBranch matches only suffix '-<branchShort>.log',

so '<base>.warning.log' is safely ignored by the failure-surfacing

poller \u2014 a merged-content run with a warning attached stays a

success in the UI. The naming mirrors the existing .partial-merge.log

precedent (R8-CC-1).

Changes:

  - distill-wrapper.sh: new log_warning helper that lazy-creates

    a sibling .warning.log with its own header (mirrors log_error

    structure). Fires when validate_commit_count > 1 right after

    the no-content early-exit and before merged-local detection.

  - agent-driven-merge.test.ts: extend multiple-commits-on-main

    test to assert .warning.log exists with WARNING text + that

    the fatal .log file does NOT exist (no false-positive failure

    surface).

  - error-log-surfacing.test.ts: add parallel test pinning that

    findDistillErrorLogForBranch ignores .warning.log files.

Findings addressed: CORR-2 (Phase C Round 1, medium).

Tests: 419 -> 420 (1 new in error-log-surfacing; 1 existing extended).
…s (PR #12 C-R1 CI-1, CLEAN-1)

Cross-reviewer consensus (CI portability + cleanness-blind): the

test-fixtures/agent-stubs/README.md was shipping in the npm bundle

despite a '!.../test-fixtures/**/README.md' exclusion line.

Root cause: order in the files array matters when broad include

globs and narrow exclude globs interleave. The previous layout was:

  "extensions/**/scripts/**",

  "!extensions/**/test-fixtures/**",

  "extensions/distill/distill-prompt.md",

  "extensions/**/README.md",

  "!extensions/**/test-fixtures/**/README.md",

The 'extensions/**/README.md' broad include re-pulls the test-

fixtures README into the bundle. The narrow '!.../README.md'

exclusion that follows is more-specific but doesn't override the

broader include because npm pack's matching evaluates them in

sequence; verified by 'npm pack --dry-run' showing the README in

the bundle.

Fix: collapse the two test-fixture exclusion lines into one broad

exclusion placed AFTER the README include, so the exclusion is the

last directive applied to test-fixture paths:

  "extensions/**/scripts/**",

  "extensions/distill/distill-prompt.md",

  "extensions/**/README.md",

  "!extensions/**/test-fixtures/**",

One exclusion covers all test-fixture files (including README and

the bash stubs) without needing the second narrow exclusion.

Verification:

  before fix: npm pack --dry-run | grep -E 'test-fixtures|agent-stubs' \u2192 1 match

  after fix:  npm pack --dry-run | grep -E 'test-fixtures|agent-stubs' \u2192 0 matches

Bundle still includes all expected sources (extensions/**/*.ts,

scripts, distill-prompt.md, extensions/**/README.md, skills, top-

level README, LICENSE).

Findings addressed: CI-1 + CLEAN-1 (Phase C Round 1, medium,

cross-reviewer consensus).

Tests: 420 (no test changes \u2014 config-only fix).
…pperWithFixture (PR #12 C-R1 CI-2, CLEAN-3)

Cross-reviewer consensus (CI portability + cleanness-blind): the

test-fixtures/agent-stubs/README.md referenced 'runWrapperWithFixture'

in _test-helpers.ts, but the actual exported helper is

'runWrapperWithStub' which accepts an 'opts.fixturePath' parameter

to point at a formal fixture file. The 'fixture' in the name lives

in the option, not the function name.

Fix the README's env-var contract section to point at the real

helper signature so a developer reading the docs can find the

code.

Findings addressed: CI-2 + CLEAN-3 (Phase C Round 1, low,

cross-reviewer consensus).

Tests: 420 (no test changes \u2014 docs-only fix).
Replace merge-driver-centric documentation with content reflecting the locked decisions in features/pi-napkin-distill/pr-12-agent-driven-merge/design.md. The agent now owns the full distill -> merge -> squash -> push -> cleanup lifecycle; the wrapper validates and salvages but never runs a per-file LLM merge driver.

Sections rewritten in README.md:

- New 'Requirements' subsection (closes CI-A-9 / R1 deferred): bash 4+, timeout(1) / gtimeout, git 2.20+, configured pi provider.

- 'Auto-init on first use' and 'How it works' no longer claim a .gitattributes scaffold is written (matches auto-setup.ts:281-283 in PR #12).

- 'Concurrency > LLM merge driver' and 'Concurrency > Partial-merge salvage' DELETED. Replaced with 'How distill resolves conflicts' (5-step flow: wrapper sets up, agent distills+merges+squashes+pushes, wrapper validates+salvages) and 'Salvage when validation fails' (V3-locked never-touch-main strategy).

- New 'Outcome classes' subsection enumerates merged-content / merged-local (NEW) / no-content / failed:<reason> with the six known reason codes (markers-after-agent-exit, pre-existing-markers, head-not-on-default, divergent-history, agent-exit-nonzero, agent-timeout) and per-class recovery actions.

- Troubleshooting > 'Merge driver fails repeatedly' replaced with 'Distill keeps failing (failed:<reason>)' pointing at the new outcome table.

- 'Testing hooks' env var table refreshed: removed deleted vars (NAPKIN_DISTILL_MERGE_MOCK, NAPKIN_DISTILL_FORCE_MERGE_HEAD, NAPKIN_DISTILL_FORCE_MERGE_RC); added the surviving Phase A/B set including NAPKIN_DISTILL_TIMEOUT_KILL_GRACE_SECS, NAPKIN_DISTILL_FORCE_CLEANUP, NAPKIN_DISTILL_NO_RECURSE.

- New 'Migration from PR #11' top-level section documenting the manual cleanup steps (per design.md 'Migration': single-user PR #11; orphaned .gitattributes rule is inert once driver is gone).

- New 'Maintenance > Verifying the agent prompt against a real LLM' subsection naming the bun run verify:agent-prompt entrypoint added in D2.

skills/napkin/SKILL.md left unchanged: it documents napkin CLI vault resolution, not the distill merge mechanism, and contains no stale references.

Tests: docs-only change, no runtime impact. 420 pass / 0 skip / 0 fail unchanged.
…) (PR #12 D2)

Adds scripts/verify-agent-prompt.ts and a 'verify:agent-prompt' npm-script entry. Replays the V2 fixture (research/v2-v3-verification.md) against the configured real LLM and asserts the same 6 post-conditions, exiting 0 on PASS / 1 on FAIL.

This is the (b) gate from PR #12's design.md 'CI vs ad-hoc V2 replay': manual on-demand re-validation when the prompt at extensions/distill/distill-prompt.md is edited or a model is rotated. CI uses the bash-stub fixtures from Phase C (extensions/distill/test-fixtures/agent-stubs/) — this script is intentionally NOT wired into CI to avoid token spend on every commit.

Behavior:

- Creates an ephemeral tmpdir vault at $TMPDIR/napkin-verify-agent-prompt-XXXXXX (cleanup automatic; --keep-tmpdir for forensic inspection).

- Sets up two divergent commits on note.md (one on main, one on a distill/verify-<sha> branch) so the agent's step 7 git merge is guaranteed to conflict.

- Builds the prompt via the actual buildDistillPrompt(...) export (not a custom V2-only prompt) — exercises the real template + placeholder substitution + bundled .md path resolution.

- Resolves the model in priority order: --model flag > vault's distill.model from $XDG_CONFIG_HOME/napkin/config.json's vault > kiro/claude-sonnet-4-6 default.

- Spawns 'pi --session ... --model ... -p $PROMPT' under a 300s wall-clock cap (configurable via --timeout-secs) with NAPKIN_DISTILL_NO_RECURSE=1 set.

- Asserts 6 post-conditions per V2: (a) no conflict markers in *.md, (b) vault HEAD on default branch, (c) agent's squash commit landed (rev-list count >= 2 after the fixture's 1 setup commit), (d) distill branch removed, (e) worktree removed, (f) note.md resolved with content from at least one source version (sanity-check the agent didn't blank the file). Prints a checklist with ✓/✗ per condition + final RESULT line.

Per task spec, no new dependencies. Uses node:child_process spawnSync, node:fs, node:os, node:path. The pi binary is resolved from PATH (or $NAPKIN_DISTILL_PI_BIN if set, matching the wrapper's testing-hook convention).

Verification: 'bun run verify:agent-prompt -- --help' prints usage cleanly and exits 0; bunx tsc --noEmit clean; biome check clean (formatting auto-applied).

Documentation: README.md's new 'Maintenance > Verifying the agent prompt against a real LLM' section (added in D1, ab89fc6) names this entry-point and points at the .md file to edit when iterating the prompt.

Scope: dev-tool only. The script is at scripts/ which is NOT in package.json's 'files' array, so 'npm pack --dry-run' confirms 15 files bundled (unchanged from pre-D2).
…pl (PR #12 D3)

design.md's 'Mocked-pi behaviors' #5 originally specified outcome 'failed' for the squash-skipped case (agent commits to the distill branch in the worktree but never squashes to the default branch). The implementation produces 'no-content' instead — and that's the correct answer.

Reasoning (decision: align spec to impl):

- From main's perspective, no new commits since startSha = no content was integrated. That is exactly what 'no-content' means in the wrapper's outcome dispatch (validate_commit_count returns 0).

- Adding a 'failed:squash-skipped' reason code would require new wrapper logic to distinguish 'agent committed in the worktree but skipped squash' from 'agent decided nothing was worth capturing'. The wrapper has no signal to tell those cases apart without inspecting the deleted distill branch in the reflog — which is fragile and racy.

- The user-facing notification semantics for 'no-content' (warning: 'distill ran but saved no content') is the right UX for both cases. The forensic distinction is recoverable from the reflog if needed.

Changes in this commit:

- extensions/distill/test-fixtures/agent-stubs/squash-skipped.sh: header comment expanded to cite CORR-3 and document the alignment decision in-line with the fixture itself.

- extensions/distill/agent-driven-merge.test.ts: 'Behavior 5: squash-skipped' comment block expanded to cite CORR-3 / Phase C R1 and pin the impl-side contract.

- README.md (already updated in D1, ab89fc6): the 'Outcome classes' table's no-content row already lists 'committed to the distill branch but skipped squash (default branch never moved)' as one of the no-content cases. No further README update needed.

Not modified (per task spec — orchestrator's responsibility post-merge):

- features/pi-napkin-distill/pr-12-agent-driven-merge/design.md (Mocked-pi behaviors #5 currently says 'failed'; orchestrator will update to 'no-content').

- features/pi-napkin-distill/pr-12-agent-driven-merge/deferred.md (CORR-3 row tracker reconciliation).

Tests: 420 pass / 0 skip / 0 fail unchanged. The squash-skipped → no-content assertion was already in place from Phase C; this commit only adds documentation linking it to CORR-3.
Five docs-only fixes flagged by Phase D Round 1 reviewers (correctness + cleanness-blind). No behavior changes; bun test stays at 420 pass / 0 fail.

- CLEAN-1 + CORR-1 (cross-reviewer consensus medium): the maintenance section pointed at scripts/verify-agent-prompt.sh but the actual file shipped in D2 is verify-agent-prompt.ts (TypeScript so it can import buildDistillPrompt from the production module). Fix the extension.

- CLEAN-2 (medium): the failed:<reason> table was missing internal-validator-error, which the wrapper has been emitting since Phase A Pass 3 when post-distill mktemp fails. Add a row describing the condition (full disk / locked-down TMPDIR ⇒ post-distill marker scan never ran) and the recovery action (manually inspect the vault for unresolved markers before relying on the squash; revert HEAD if needed; reflog keeps the distill content for ~90 days).

- CLEAN-3 (low): two README cross-references pointed at features/pi-napkin-distill/.../design.md and features/pi-napkin-distill/builder-deleter — paths that don't exist in the published repo (the design doc lives in the maintainer's vault, not git). Drop the parenthetical at line 428 (the surrounding migration prose stands alone) and trim the dangling 'See [features/...] (design pending)' link at the end of the builder-deleter section.

- CLEAN-5 (nit): the maintenance section claimed verify:agent-prompt asserts 4 post-conditions ('no skipped procedural steps, no conflict markers, HEAD on default, distill branch removed') but the script asserts 6 and 'no skipped procedural steps' isn't one of them. Reword to 'asserts the wrapper's documented post-conditions (see the script header for the full six-item list: no conflict markers, HEAD on default, agent's squash committed, distill branch removed, worktree removed, conflicted note resolved cleanly)'.

All findings: README.md only. No code changes; tsc --noEmit clean; biome check clean.
…-Fix-1)

Three docstring-vs-code drift fixes flagged by Phase D Round 1 reviewers (correctness + cleanness-blind). No code changes; bun test stays at 420 pass / 0 fail.

- CORR-2 (low): the file-header docstring slot (e) promised the script asserts 'No --force push attempted', but assertPostConditions actually checks 'worktree removed from git worktree list' (the fixture has no remote, so the V2 force-push slot is unverifiable; the substitution picks up an actually-meaningful design.md post-validation requirement). Update the docstring to describe what the code asserts, with a one-line note on why the substitution was made.

- CORR-3 (nit): the docstring said 'rev-list --count > 0' for slot (c), but the code requires >= 2 because setupFixture pre-adds one 'main edit' commit before pi runs (so any agent squash lands as the second commit). The inline comment in assertPostConditions already explains this; only the file-header was stale. Update to '>= 2' with the rationale spelled out.

- CLEAN-4 (low): the docstring referenced features/pi-napkin-distill/.../design.md (line 8) and research/v2-v3-verification.md (line 39) — paths that don't exist in the published repo. Drop both parentheticals; the surrounding prose stands alone, and the design context is captured in PR #12 itself.

All findings: scripts/verify-agent-prompt.ts docstring only. No runtime impact; tsc --noEmit clean; biome check clean.
…e-merge gate)

Phase A Pass 2A's CLEAN-5 fix added an explicit no-content branch at step 7 of distill-prompt.md. The verify-agent-prompt fixture invoked pi against an empty --session, and the agent (correctly) entered the no-content branch — skipping merge/squash and failing the post-condition that the agent's squash should land on default.

Pre-populate <tmpdir>/session.jsonl with a synthetic 6-message user-assistant conversation about Node spawnSync EPIPE/SIGPIPE handling. The agent now reads a substantive conversation, decides per 'Be selective' that yes-this-merits-capture, and proceeds through step 7's content path.

Verified: 3 consecutive runs of bun run verify:agent-prompt all pass 6/6 post-conditions (171s, 166s, 163s). Pre-merge gate now holds.
…ite race

Reproduces the production bug where 'Distillation terminated abnormally — no outcome record' fires falsely after a successful merged-content distill. Cause: the agent runs step 10 (`git worktree remove`) before the wrapper writes the outcome sidecar; the JS-side poller in `runDistillWith` ticks during the [worktree-gone, outcome-written) window and `findDistillOutcomeForBranch` returns null, which `formatOutcomeNotification` classifies as 'terminated abnormally'.

This test FAILS on current main (race triggered, 6 expects, last assertion observes outcomeAtRaceWindow=null) and will PASS after the fix lands. Do NOT skip this test in CI — the failing assertion pins the regression so a future 'fix' that doesn't actually close the race window cannot silently regress.

Test approach: Strategy A — spawn the wrapper detached (mirroring `spawnDistillInWorktree`'s production shape: `detached:true` + `unref()`) and poll worktree disappearance from JS at 50 ms intervals, snapshotting the outcome file's existence at the exact tick where production's poller would call `checkOutcome`. The new fixture `step10-race.sh` widens the race window deterministically by sleeping 0.5 s after `git worktree remove` and before exiting, which keeps the wrapper blocked on the agent subprocess long enough for the JS poller's tick to land inside the window. Why existing bash-stub fixtures miss this: none of them call `git worktree remove`, so the worktree-disappearance signal coincides with the wrapper's EXIT trap (which fires after `write_outcome`), and the race window never opens in tests.
…ies to _test-helpers.ts

Move the fake UI factory (notifyCalls + setStatusCalls capture) and the
spy-style ExtensionAPI factory out of routing.test.ts into the shared
_test-helpers module so other consumers (notably the upcoming verify:e2e
script) can reuse them without duplicating the boilerplate.

The fake UI preserves the existing { msg, severity } notification shape,
keeping the ~10 c.msg.startsWith(...) consumer references in
routing.test.ts unchanged. routing.test.ts collapses three local
definitions (the inline UI fake, the makeUI() factory, and the
makeMockExtensionAPI() function) to a pair of imports.
…ntime gate

verify-e2e exercises the production runtime end-to-end: the manual
/distill command handler triggers runDistillWith, which spawns the real
wrapper subprocess via worktreeSpawnFn, the wrapper invokes pi -p
against a real LLM, and the JS-side setInterval poller observes the
wrapper's outcome sidecar and dispatches a UI notification through the
captured fake UI.

Strict superset of the prompt-only gate it replaces:

- The prompt-only gate spawned pi synchronously via spawnSync, bypassed
  the wrapper, and ran no JS-side polling. It asserted on filesystem
  post-conditions only.
- verify:e2e covers all those filesystem post-conditions PLUS the
  wrapper's outcome class AND the JS-side notification severity AND the
  bare-origin push (which the prompt-only harness couldn't validate).

The fixture uses a sibling-layout config with vault.root: '..' and
distill.enabled = true so runDistill routes through worktreeSpawnFn
(rather than legacy-embedded fallback). Origin is bare so the wrapper's
push lands without receive.denyCurrentBranch=ignore — non-bare init
would steer the outcome to merged-local instead of merged-content.

Session ownership: SessionManager.create() allocates the path,
writeSyntheticSession() writes the JSONL content. The wrapper's
session-fork step reads from disk via getSessionFile(); content on disk
before the wrapper runs is what matters.

Trigger: captured.commands.distill.handler('', ctx) — bypasses the
60-minute auto-distill interval; the manual handler invokes runDistill
immediately.

Helper extraction: writeSyntheticSession (and the synthetic 6-message
conversation it embeds) moves to scripts/_e2e-helpers.ts so the on-demand
harness keeps the JSONL format knowledge in one place.
…pper handles it

Step 10 instructed the agent to run `git worktree remove` itself,
but the wrapper's EXIT trap already removes the worktree
unconditionally after writing the outcome sidecar. Two cleanup
paths running back-to-back (agent then wrapper) opens a race
window: the JS-side poller in `runDistillWith` ticks every ~2s
and observes worktree disappearance (caused by the agent's step
10) before the wrapper has finished post-validation and written
the outcome. `checkOutcome` then returns null, dispatching a
spurious "Distillation terminated abnormally — no outcome
record" warning on a successful merged-content distill.

Drop step 10 from the prompt. The wrapper has always been the
actual cleanup mechanism; the "agent owns cleanup" claim in PR
#12's original design was aspirational. Keeping one cleanup path
(wrapper-side, after `write_outcome`) closes the race
structurally without losing any capability.

Test changes match the prompt:
- 9 numbered steps, not 10
- Negative assertion: prompt does NOT contain agent-side
  worktree-remove or branch -D directives
- Step 7's no-content branch: 'skip steps 7-9 entirely and exit'
- Snapshot regenerated

The salvage path has the same race surface (worktree removed
before `write_outcome`); a follow-up commit reorders salvage
to write outcome first.
…oval

The stub at test-fixtures/agent-stubs/step10-race.sh used to mirror
the agent's old step 10 (`git worktree remove --force` followed by
`git branch -D`) to reproduce the production race. Now that the
agent prompt has dropped step 10 (the wrapper's EXIT trap is the sole
worktree-removal path on the happy path), the stub no longer needs
to remove the worktree itself — leaving it matches post-fix
production behaviour exactly (agent commits content + exits;
wrapper handles cleanup).

Keep `sleep 0.5` after the agent's commit work so the wrapper's
post-validation + write_outcome + EXIT-trap cleanup chain happens at
a known wall-time offset relative to the JS-side poll. This widens
the [agent-exit, worktree-removed] gap deterministically for the
happy-path case in the upcoming wrapper-invariant test.

Update race-step10-cleanup.test.ts:
- Docstring rewritten in timeless framing: regression guard for the
  wrapper invariant 'write_outcome runs before any worktree removal
  on the happy path', not 'reproduces the bug'.
- Step-10 references retained as historical context (the test
  originated as the deterministic reproducer for the agent-step-10
  race).
- Assertion message updated to flag the invariant directly rather
  than the historic agent-side cause.
- Test name no longer says 'FAILS on current main — bug'.

The race-step10-cleanup test transitions FAIL→PASS with this commit
because the modified stub no longer opens the race window the test
was designed to catch. The broader wrapper invariant (covering both
happy and salvage paths) is pinned by a new wrapper-invariant.test.ts
in the next commit.
…e ordering

Pin the wrapper invariant: `write_outcome` runs BEFORE any worktree-
removal step anywhere in the wrapper. The JS-side poller in
runDistillWith watches the worktree path for disappearance — that's
its completion signal. If the wrapper removes the worktree before
writing the outcome sidecar, the poller's tick can land in the gap
and observe `worktree-gone AND outcome-not-written`, dispatching a
spurious 'terminated abnormally' warning on what was actually a
successful (or cleanly-failed) distill.

This commit covers the happy path (wrapper EXIT trap fires AFTER
write_outcome). The salvage path is pinned by a follow-up commit.

Mechanism: spawn the wrapper detached (mirrors spawnDistillInWorktree's
production shape), poll the filesystem at 50 ms intervals, snapshot
the outcome file at the moment the worktree disappears.

Note: this test does NOT use runWrapperWithStub from _test-helpers.ts
because that helper uses spawnSync, which blocks the test until the
wrapper has fully exited — by that time the worktree is long gone,
the outcome is long written, and the mid-execution race window is
invisible. The raw spawn + child.unref() + concurrent polling pattern
(proven in race-step10-cleanup.test.ts) is required to observe the
filesystem state at the exact moment the worktree disappears.

Reuses test-fixtures/agent-stubs/step10-race.sh as the fixture — it
already commits content + sleeps + exits without removing the
worktree itself, exactly the shape needed for the happy-path race-
window-widening.
…ome ordering

Pin the wrapper invariant 'write_outcome runs before any
worktree-removal step anywhere in the wrapper' for the salvage
path: stub-pi commits content WITH conflict markers + exits 0;
wrapper's `validate_no_markers` fails and routes into
`salvage("markers-after-agent-exit")`. Assert outcome sidecar
exists with class `failed:markers-after-agent-exit` at the
moment worktree disappears.

This test FAILS on the wrapper's pre-reorder salvage code
because `salvage()` currently removes the worktree (lines
~556-575) BEFORE writing the outcome (line ~623). The JS-side
poller can race-observe worktree-gone with no outcome yet,
dispatching 'terminated abnormally' instead of
`failed:markers-after-agent-exit`. The next commit reorders
salvage so write_outcome runs first; this test transitions
RED \u2192 GREEN there.

Mechanism details:

- New stub `test-fixtures/agent-stubs/salvage-race.sh` commits
  a vault `*.md` file with a complete conflict-marker triple
  (`<<<<<<<` / `=======` / `>>>>>>>`), then exits 0. The
  wrapper's post-validation detects the markers and routes into
  the salvage path with reason `markers-after-agent-exit`.

- New PATH shim `test-fixtures/slow-git.sh` widens the race
  window inside the wrapper. Unlike the happy path (where the
  stub's `sleep 0.5` widens the [agent-exit, worktree-removed]
  gap), the salvage path's race is INSIDE the wrapper itself
  (between `git worktree remove` returning and `write_outcome`
  being called). The shim runs the real git FIRST so the
  worktree disappears immediately, THEN sleeps 0.5 s before
  returning to the wrapper \u2014 widens the [worktree-gone,
  write_outcome] interval by ~500 ms.

  Ordering matters: exec-then-sleep opens the race window;
  sleep-then-exec would NOT (worktree wouldn't disappear until
  after the sleep, and the wrapper would resume at normal speed
  once it does).

- The shim resolves the real `git` via `NAPKIN_SLOW_GIT_REAL_GIT`
  (set by the test before mutating PATH). Avoids
  PATH-introspection edge cases (BASH_SOURCE+symlink resolution,
  recursive shim lookup) inside the shim itself.

- Test-side: stage the shim as `<tmpdir>/git` and prepend the
  tmpdir to PATH so the wrapper's bare `git ...` invocations
  resolve through it. Cleanup restores PATH and rm -rf's the
  tmpdir.

Test count: 423 pass / 1 fail (the new salvage test fails as
RED gate; the happy-path test from the prior commit + the
race-step10-cleanup test continue to pass). The salvage test
transitions to PASS in the next commit.
The wrapper has two cleanup pathways. The EXIT trap (happy path)
already runs write_outcome BEFORE removing the worktree. The
salvage path (failure recovery) had the opposite order: cd out
of worktree, force-remove worktree, prune, branch -D, rmdir
parent, then verify HEAD, compose recovery hint, and write
outcome.

This opens the same race window as the agent-step-10 bug: the
JS-side poller in runDistillWith watches the worktree path and
calls findDistillOutcomeForBranch as soon as fs.existsSync
returns false. With salvage's old ordering, the poller could
race-observe worktree-gone before write_outcome ran, dispatching
a spurious 'terminated abnormally \u2014 no outcome record' warning
instead of the correct 'failed:<reason>' notification.

Reorder salvage() to mirror the EXIT trap:
  1. cd out of the worktree (unchanged)
  2. Compose recovery hint (case-block) \u2014 moved up
  3. write_outcome 'failed:<reason>' \u2014 moved up (before any
     worktree-removal step; this is the function-level invariant)
  4. Verify HEAD on default; log_error if not \u2014 moved up
  5. THEN remove worktree: git worktree remove --force \u2192
     safe_rm_worktree \u2192 git worktree prune \u2192 git branch -D \u2192
     rmdir parent

Pure motion, ~30 LOC. All existing invariants preserved:
- Best-effort cleanup (each command tolerates failure with
  '|| true')
- Never-touch-main (no git checkout / git reset)
- Recovery-hint formatting per reason code (markers-after-agent-
  exit, pre-existing-markers, internal-validator-error,
  head-not-on-default, agent-exit-nonzero, agent-timeout,
  divergent-history)
- log_error lines for HEAD detached / HEAD not on default

Add a function-level docstring noting the invariant explicitly:
'write_outcome ALWAYS runs before any worktree-removal step in
this function, mirroring the EXIT trap's ordering.'

The wrapper-invariant.test.ts salvage-path case transitions
RED \u2192 GREEN with this commit. Test count: 424 pass / 0 fail.
The maintenance section's ad-hoc real-LLM verification step now points
at `bun run verify:e2e` (the full-runtime gate that exercises wrapper
subprocess + agent + JS-side polling end-to-end) instead of the
prompt-only `verify:agent-prompt` predecessor that the package no
longer ships. The new description captures the strict-superset
post-conditions (notification severity + message, plus filesystem
post-conditions including the bare-origin push) and notes the manual /
~$0.50-per-run nature of the gate.
@cad0p

cad0p commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

Wrong target — opening on cad0p fork instead.

@cad0p cad0p closed this May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant