Skip to content

feat(phase 1): first-wins harness/block + doc subagent isolation + README correction - #69

Merged
yogthos merged 1 commit into
mainfrom
feat/phase1-hook-semantics-docs
May 21, 2026
Merged

feat(phase 1): first-wins harness/block + doc subagent isolation + README correction#69
yogthos merged 1 commit into
mainfrom
feat/phase1-hook-semantics-docs

Conversation

@yogthos

@yogthos yogthos commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Phase 1 of the 6-phase deferred-items plan. First-blocker-wins matches pi's tool_call hook semantics. Subagent isolation documented as intentional (matches opencode + pi). README's incorrect 'buffered on retry' claim fixed. 3 new tests, 637 pass.

…ADME stream fix

Phase 1 of the deferred-items plan. Reference patterns: pi's
`runner.ts` block semantics, opencode's subagent isolation.

## First-blocker-wins for harness/block

Previously last-write-wins: if two plugins registered `on-tool-start`
hooks and both called `(harness/block reason)`, the second
overwrote the first. Block reason became load-order-dependent and
hooks ran after a deny that obviously shouldn't proceed.

Now matches pi's `packages/coding-agent/src/core/extensions/runner.ts:806-827`
`tool_call` semantics: as soon as ANY plugin sets `harness-block`,
dispatch stops and subsequent plugins do NOT run for this tool
call. First blocker's reason wins.

`harness/mutate-input` and `harness/replace-result` keep last-
write-wins chaining (matches pi's `runner.ts:858-888` and
opencode's `plugin.ts:102-134` Immer-draft shared mutation), so
successive plugins can refine each other's mutations.

Implementation: `dispatch_tool_hook` now iterates hooks one-by-
one rather than reusing `dispatch()`. After each hook returns,
peek `harness-block` via the new private `has_pending_block`
helper — break out of the loop if set. Inlined the per-hook
catch-wrap (sanitize error → push notif → tracing::warn) so
the structure mirrors `dispatch()` without sharing.

## README "buffered on retry" claim corrected

The README claimed "stream events are buffered and only flushed
on success." False — `runner.rs::run_stream` emits Token /
Reasoning / ToolCall events live as they arrive. On retry the
UI shows "(error: …; retrying)" and the next attempt's tokens
stream fresh. Tool calls dispatched before failure still inhibit
retry (correct). Updated the wording.

Also mentions `overloaded_error` in the retry list (added in
the previous PR).

## docs/PLUGINS.md: hook precedence + subagent isolation

New bullets in the "Subtle distinctions" section:

- First-wins block semantics for multi-plugin `on-tool-start`,
  with reference to pi's design.
- Subagent isolation: when the LLM calls `task`, dirge runs a
  one-shot LLM query with no tools / no hooks / no permission
  gates. Matches opencode's bare-LLM subagent and pi's full-
  subprocess design — isolation is intentional. Plugin authors
  who want subtask observability should route through
  `harness/log` from the parent's `on-response` hook.

## Tests

3 new tests, written failing first:

- `dispatch_tool_hook_first_blocker_stops_dispatch` — two
  blockers, second has a notify side-effect; assert the second
  never fired AND the first's reason is the result.
- `dispatch_tool_hook_runs_all_when_no_block` — confirms early-
  stop only triggers on actual blocks.
- `dispatch_tool_hook_mutations_still_chain_last_wins` — guards
  against accidentally also short-circuiting on mutation slots.

Updated `test_dispatch_tool_hook_block_sticks_across_hooks` →
removed (replaced by the new first-blocker test which asserts
stricter semantics).

637 pass total, 0 fail, all build profiles clean.

## Out-of-scope items now integrated into the plan

The audit's deferred items have been folded into a 6-phase plan:

- **Phase 1** (this PR): semantic fix + docs.
- **Phase 2**: sibling-branch pruning on compress/rewind
  (opencode's drop-with-notification pattern).
- **Phase 3**: tool-call structured persistence with
  interrupted-state pairing (opencode's `ToolPart` pattern).
- **Phase 4**: pi-style `BranchSummaryMessage` (richer
  preservation if Phase 2 proves too lossy).
- **Phase 5**: `/allow` CRUD slash command.
- **Phase 6**: cost tracking (per-provider pricing + actual
  usage extraction).

Phases 4-6 were initially listed as "out of scope" but the user
asked for an integrated plan; documenting here so they're not
forgotten.

## Test plan

- [x] `cargo test --features plugin` -> 637 pass.
- [x] `cargo build --all-features` -> compiles, 0 warnings.
@yogthos
yogthos merged commit d209366 into main May 21, 2026
1 check passed
@yogthos
yogthos deleted the feat/phase1-hook-semantics-docs branch May 21, 2026 02:33
yogthos added a commit that referenced this pull request May 21, 2026
)

Phase 5 of the plan. No clean equivalent in opencode or pi — both
manage allowlist entries only via the interactive permission
prompt's "allow always" answer. dirge adds explicit CRUD so users
can inspect, manually add, drop one, or clear all entries without
editing the session JSON.

## Commands

- `/allow` or `/allow list` — show numbered entries
- `/allow add <tool> <pattern>` — add a (tool, pattern) entry,
  e.g. `/allow add bash 'cargo *'`
- `/allow remove <idx>` — drop one entry by 0-based index from
  the `list` output
- `/allow clear` — drop all entries

The `add` form preserves pattern strings containing spaces by
re-deriving the args from the raw `text` slice (rather than
relying on the SmallVec `parts[2]` which would get truncated at
the first whitespace). Same approach `/cd` uses for paths.

## State sync

Both surfaces stay aligned:
- `PermissionChecker::session_allowlist` (in-memory, used by
  `check` / `check_path`) — updated via the existing
  `add_session_allowlist` and the two new
  `remove_session_allowlist_at(idx)` + `clear_session_allowlist`.
- `Session::permission_allowlist` (persisted to JSON, restored
  on resume via `load_session_allowlist`) — mirrored
  manually in the slash handler so save/load round-trips show
  the user-edited list.

Without the session-side mirror, a `/allow add foo bar` would
stay in memory for this run but vanish on `-c` resume. Dedup at
the session level too so `save()` writes a clean list.

## Tests

3 new permission tests, written failing first:

- `remove_session_allowlist_at_returns_removed_entry`:
  remove(1) on [bash:cargo*, bash:git*, read:/tmp/*] yields
  Some(("bash","git *")) and the surviving entries shift down.
- `remove_session_allowlist_at_out_of_range_returns_none`:
  remove(99) returns None without panicking; existing entries
  intact.
- `clear_session_allowlist_empties_the_list`: clear() drops
  all entries.

The slash handler itself is not unit-tested (requires the full
UI loop fixtures); manual smoke test:

```
$ dirge --restrictive
> bash 'echo hi'
> (a) allow always
> /allow list      → [0] bash echo *
> /allow add read /tmp/*
> /allow list      → [0] bash echo *  [1] read /tmp/*
> /allow remove 0
> /allow list      → [0] read /tmp/*
> /allow clear
> /allow list      → empty
```

## Docs

`/help` text gets four new lines covering each subcommand.

## Test plan

- [x] `cargo test --features plugin` -> 649 pass (was 646).
- [x] `cargo build --all-features` -> compiles.
- [x] `cargo build --no-default-features` -> compiles.

## Plan status

| Phase | PR | Done |
|---|---|---|
| 1 | #69 | first-wins block + docs |
| 2 | #70 | sibling-branch pruning + notification |
| 3 | #71 | structured tool-call persistence |
| 4 | #72 | branch summary metadata in /tree |
| 5 | this | /allow CRUD |
| 6 | — | skipped per user request (cost tracking) |

5 of 6 phases complete; Phase 6 (cost tracking) skipped.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…ADME stream fix (dirge-code#69)

Phase 1 of the deferred-items plan. Reference patterns: pi's
`runner.ts` block semantics, opencode's subagent isolation.

## First-blocker-wins for harness/block

Previously last-write-wins: if two plugins registered `on-tool-start`
hooks and both called `(harness/block reason)`, the second
overwrote the first. Block reason became load-order-dependent and
hooks ran after a deny that obviously shouldn't proceed.

Now matches pi's `packages/coding-agent/src/core/extensions/runner.ts:806-827`
`tool_call` semantics: as soon as ANY plugin sets `harness-block`,
dispatch stops and subsequent plugins do NOT run for this tool
call. First blocker's reason wins.

`harness/mutate-input` and `harness/replace-result` keep last-
write-wins chaining (matches pi's `runner.ts:858-888` and
opencode's `plugin.ts:102-134` Immer-draft shared mutation), so
successive plugins can refine each other's mutations.

Implementation: `dispatch_tool_hook` now iterates hooks one-by-
one rather than reusing `dispatch()`. After each hook returns,
peek `harness-block` via the new private `has_pending_block`
helper — break out of the loop if set. Inlined the per-hook
catch-wrap (sanitize error → push notif → tracing::warn) so
the structure mirrors `dispatch()` without sharing.

## README "buffered on retry" claim corrected

The README claimed "stream events are buffered and only flushed
on success." False — `runner.rs::run_stream` emits Token /
Reasoning / ToolCall events live as they arrive. On retry the
UI shows "(error: …; retrying)" and the next attempt's tokens
stream fresh. Tool calls dispatched before failure still inhibit
retry (correct). Updated the wording.

Also mentions `overloaded_error` in the retry list (added in
the previous PR).

## docs/PLUGINS.md: hook precedence + subagent isolation

New bullets in the "Subtle distinctions" section:

- First-wins block semantics for multi-plugin `on-tool-start`,
  with reference to pi's design.
- Subagent isolation: when the LLM calls `task`, dirge runs a
  one-shot LLM query with no tools / no hooks / no permission
  gates. Matches opencode's bare-LLM subagent and pi's full-
  subprocess design — isolation is intentional. Plugin authors
  who want subtask observability should route through
  `harness/log` from the parent's `on-response` hook.

## Tests

3 new tests, written failing first:

- `dispatch_tool_hook_first_blocker_stops_dispatch` — two
  blockers, second has a notify side-effect; assert the second
  never fired AND the first's reason is the result.
- `dispatch_tool_hook_runs_all_when_no_block` — confirms early-
  stop only triggers on actual blocks.
- `dispatch_tool_hook_mutations_still_chain_last_wins` — guards
  against accidentally also short-circuiting on mutation slots.

Updated `test_dispatch_tool_hook_block_sticks_across_hooks` →
removed (replaced by the new first-blocker test which asserts
stricter semantics).

637 pass total, 0 fail, all build profiles clean.

## Out-of-scope items now integrated into the plan

The audit's deferred items have been folded into a 6-phase plan:

- **Phase 1** (this PR): semantic fix + docs.
- **Phase 2**: sibling-branch pruning on compress/rewind
  (opencode's drop-with-notification pattern).
- **Phase 3**: tool-call structured persistence with
  interrupted-state pairing (opencode's `ToolPart` pattern).
- **Phase 4**: pi-style `BranchSummaryMessage` (richer
  preservation if Phase 2 proves too lossy).
- **Phase 5**: `/allow` CRUD slash command.
- **Phase 6**: cost tracking (per-provider pricing + actual
  usage extraction).

Phases 4-6 were initially listed as "out of scope" but the user
asked for an integrated plan; documenting here so they're not
forgotten.

## Test plan

- [x] `cargo test --features plugin` -> 637 pass.
- [x] `cargo build --all-features` -> compiles, 0 warnings.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…irge-code#73)

Phase 5 of the plan. No clean equivalent in opencode or pi — both
manage allowlist entries only via the interactive permission
prompt's "allow always" answer. dirge adds explicit CRUD so users
can inspect, manually add, drop one, or clear all entries without
editing the session JSON.

## Commands

- `/allow` or `/allow list` — show numbered entries
- `/allow add <tool> <pattern>` — add a (tool, pattern) entry,
  e.g. `/allow add bash 'cargo *'`
- `/allow remove <idx>` — drop one entry by 0-based index from
  the `list` output
- `/allow clear` — drop all entries

The `add` form preserves pattern strings containing spaces by
re-deriving the args from the raw `text` slice (rather than
relying on the SmallVec `parts[2]` which would get truncated at
the first whitespace). Same approach `/cd` uses for paths.

## State sync

Both surfaces stay aligned:
- `PermissionChecker::session_allowlist` (in-memory, used by
  `check` / `check_path`) — updated via the existing
  `add_session_allowlist` and the two new
  `remove_session_allowlist_at(idx)` + `clear_session_allowlist`.
- `Session::permission_allowlist` (persisted to JSON, restored
  on resume via `load_session_allowlist`) — mirrored
  manually in the slash handler so save/load round-trips show
  the user-edited list.

Without the session-side mirror, a `/allow add foo bar` would
stay in memory for this run but vanish on `-c` resume. Dedup at
the session level too so `save()` writes a clean list.

## Tests

3 new permission tests, written failing first:

- `remove_session_allowlist_at_returns_removed_entry`:
  remove(1) on [bash:cargo*, bash:git*, read:/tmp/*] yields
  Some(("bash","git *")) and the surviving entries shift down.
- `remove_session_allowlist_at_out_of_range_returns_none`:
  remove(99) returns None without panicking; existing entries
  intact.
- `clear_session_allowlist_empties_the_list`: clear() drops
  all entries.

The slash handler itself is not unit-tested (requires the full
UI loop fixtures); manual smoke test:

```
$ dirge --restrictive
> bash 'echo hi'
> (a) allow always
> /allow list      → [0] bash echo *
> /allow add read /tmp/*
> /allow list      → [0] bash echo *  [1] read /tmp/*
> /allow remove 0
> /allow list      → [0] read /tmp/*
> /allow clear
> /allow list      → empty
```

## Docs

`/help` text gets four new lines covering each subcommand.

## Test plan

- [x] `cargo test --features plugin` -> 649 pass (was 646).
- [x] `cargo build --all-features` -> compiles.
- [x] `cargo build --no-default-features` -> compiles.

## Plan status

| Phase | PR | Done |
|---|---|---|
| 1 | dirge-code#69 | first-wins block + docs |
| 2 | dirge-code#70 | sibling-branch pruning + notification |
| 3 | dirge-code#71 | structured tool-call persistence |
| 4 | dirge-code#72 | branch summary metadata in /tree |
| 5 | this | /allow CRUD |
| 6 | — | skipped per user request (cost tracking) |

5 of 6 phases complete; Phase 6 (cost tracking) skipped.

Co-authored-by: Yogthos <yogthos@gmail.com>
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