@moikapy/lich 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +34 -8
  3. package/dist/{chunk-P52U5M3L.js → chunk-MLFJW4JU.js} +399 -70
  4. package/dist/chunk-MLFJW4JU.js.map +1 -0
  5. package/dist/cli.d.ts +1 -2
  6. package/dist/cli.js +4 -3
  7. package/dist/cli.js.map +1 -1
  8. package/dist/{gateway-CWPVIU3W.js → gateway-XTYDYT67.js} +2 -2
  9. package/dist/index.d.ts +25 -11
  10. package/dist/index.js +1 -1
  11. package/dist/{tui-V7ATLIKW.js → tui-VYBJSGRV.js} +6 -3
  12. package/dist/tui-VYBJSGRV.js.map +1 -0
  13. package/docs/.vitepress/config.mts +1 -0
  14. package/docs/architecture/extending.md +5 -2
  15. package/docs/architecture/overview.md +9 -7
  16. package/docs/architecture/plugins.md +58 -5
  17. package/docs/architecture/tools.md +16 -4
  18. package/docs/design/council/architecture-review-r2.md +36 -0
  19. package/docs/design/council/architecture-review-r3.md +69 -0
  20. package/docs/design/council/index.md +15 -0
  21. package/docs/design/council/security-review-r2.md +32 -0
  22. package/docs/design/council/security-review-r3.md +19 -0
  23. package/docs/design/council/simplicity-review-r2.md +35 -0
  24. package/docs/design/council/simplicity-review-r3.md +22 -0
  25. package/docs/design/self-improvement-loop.md +160 -162
  26. package/docs/getting-started.md +37 -15
  27. package/docs/index.md +11 -11
  28. package/docs/user-guide/cli.md +18 -5
  29. package/docs/user-guide/gateway.md +10 -10
  30. package/docs/user-guide/library.md +15 -9
  31. package/docs/user-guide/plugins.md +59 -6
  32. package/docs/user-guide/tui.md +3 -3
  33. package/package.json +4 -2
  34. package/dist/chunk-P52U5M3L.js.map +0 -1
  35. package/dist/tui-V7ATLIKW.js.map +0 -1
  36. /package/dist/{gateway-CWPVIU3W.js.map → gateway-XTYDYT67.js.map} +0 -0
@@ -0,0 +1,36 @@
1
+ # Council Review R2 — Architecture & Testability
2
+
3
+ Reviewer: Council Member 3 (architecture fit, correctness, testability)
4
+ Plan under review: `docs/design/self-improvement-loop.md` Draft v2
5
+
6
+ ## Verdict
7
+
8
+ APPROVE-WITH-CHANGES. All seven round-1 findings are materially addressed — three by clean cuts, four by concrete redesign — but v2 introduces one genuine mechanism error (the HEAD+index seal does not close the TOCTOU it claims to close: the plan's own negative-path demo would pass the broken seal) and leaves the core-plugin config plumbing unspecified.
9
+
10
+ ## Round-1 findings status
11
+
12
+ 1. Executor 30s cap — **RESOLVED.** `tool.timeout_ms ?? 30000` in `run_tool` is exactly the right seam; the signal-merge logic in `with_timeout` (timeout signal + external signal → combined controller, `src/tools/executor.ts:50-70`) needs no change, only the constant swap. Residual: the latent `terminal` 300s-vs-30s mismatch is not mentioned — give `terminal_tool` `timeout_ms: 300000` while touching the interface.
13
+ 2. Run-scoped state — **RESOLVED.** `run_state: Map` on `HookContext` + loop passing a per-run `ToolContext` fixes the `process.cwd()` bug, and it is consistent with executor defaults: `ToolExecutor.execute` uses defaults only when context is absent (`src/tools/executor.ts:39-43`), so a loop-supplied context simply supersedes them. Existing loop tests are safe — stub runners are `execute: async (name, args)` (`test/loop.test.ts:35-42`) and the interface is structural with an optional third param. Two threading details to name: `ToolContext` must carry `run_state` too (or `hooks.ts` cannot map it into `HookContext`), and the lifecycle-hook ctx built in `Agent.run` must hold the SAME map instance as the loop's ToolContext.
14
+ 3. on_run_end reliability — **RESOLVED.** Reflector is cut entirely (v2 scope §5, non-goals); nothing consumes `on_run_end` on crash paths anymore, so the `outcome === undefined` drop is moot.
15
+ 4. Consolidation ChatFn — **RESOLVED.** Cut; no plugin needs config for LLM calls anymore (but see new finding 2 — the gatekeeper still needs config for other keys).
16
+ 5. Two-process demo — **PARTIALLY RESOLVED.** The A/B structure, negative paths, and honest module-cache statement are right, but Process B's mechanism is ambiguous to the point of being wrong (see new finding 4).
17
+ 6. run_tests concurrency — **PARTIALLY RESOLVED.** Module-level mutex with `run_tests_busy` is specified and fine for one process, but lich legitimately runs CLI + TUI/gateway as separate processes on one repo; the mutex does not cover that. Either an `O_EXCL` file lock or an explicit documented limitation.
18
+ 7. Docs-cache multi-root — **PARTIALLY RESOLVED.** Per-root keying is the right direction, but "invalidated per call batch" is not a design; and since skills are now written via plain `write_file`, there is no hook point to trigger invalidation — a skill written this run stays invisible to `docs_search` until restart. Concrete fix: per-root `Map` cache, and NO memoization for user-writable roots (`.lich/skills` is tiny; walk it fresh every call), cache only the package/docs root.
19
+
20
+ ## New blocking findings in v2
21
+
22
+ 1. **The seal does not close the TOCTOU for unstaged edits — the plan's own negative-path test would pass. HIGH.** v2 §3 claims "edit changes the index hash"; false: `git write-tree` (the only primitive that hashes the index) captures STAGED state only — an unstaged edit to a tracked file leaves the index byte-identical, so test→edit→commit still matches the seal. Worse, `before_tool_call` runs BEFORE `git_commit` executes its `git add`, so whatever the gatekeeper compares is pre-add. The v2 demo's "commit after editing without re-testing → vetoed" would NOT be vetoed as specified. Fix (must be in the spec): the gatekeeper computes the hypothetical post-add tree itself via a temp index — `GIT_INDEX_FILE=<tmp> git read-tree <current-index>` (or copy), then `git add -- <paths>` against the temp index, then `git write-tree`, and compares THAT hash to the seal. This also naturally captures the named paths' working-tree content, not stale staged content. NFS note: stale attribute caches can produce phantom diffs between seal capture and commit (the "documented phantom-file problem" is actually only empirical in this repo — no docs page documents it; evidence is the `.git/tkfNobv: Function not implemented` failure and the suite-cleanup flake noted in council/security-review.md) — direction of failure is a spurious veto (fail-closed, acceptable), but fixtures belong on `/tmp`, not `test/.tmp/`.
23
+ 2. **Core-plugin config plumbing is unspecified. HIGH.** Plugins never receive config: the loader extracts only `name`/`tools`/`hooks` (`src/plugins/loader.ts:48-60`) and `Agent` never passes config to plugins. Yet the gatekeeper needs `allow_self_commit` and `max_commits_per_run`, `run_tests` needs `test_command`, and docs tools need `docs_roots` — and builtin tools don't see config either (`ToolContext` is `{work_dir, env, signal}`, `src/tools/types.ts:9-13`). Fix, using existing precedents: (a) `Agent` constructs the builtin gatekeeper directly (not via `load_plugins`) with config in hand — that is also the only clean way to honor "git_commit registered only if gatekeeper loaded"; (b) tool-visible config rides the existing `ExecutorDefaults.env` channel exactly like `LICH_TERMINAL_TIMEOUT_MS` and `LICH_DOCS_DIR` already do (`src/agent/agent.ts:110-113`): `LICH_TEST_COMMAND`, `LICH_DOCS_ROOTS`. Name both paths in the spec.
24
+ 3. **`run_tests` cwd pin contradicts the demo. HIGH.** v2 §1 pins cwd to the lich repo root via `import.meta.url`, but the demo (§6) runs against a temp git repo work_dir — files the agent writes into the fixture are never tested by a test command rooted at the real repo. Fix: cwd = `context.work_dir` (matching the config-derived env above), with the default `test_command` from config; the real repo is just the normal case where work_dir IS the lich root.
25
+ 4. **Process B will not see `hash_text` if it is a same-process "fresh agent instance". HIGH.** Registry is built in the constructor from `register_builtin_tools` (`src/agent/agent.ts:108-114`), and bun caches `src/tools/builtin/index.js` per process — a second in-process Agent imports the OLD module and `hash_text` never registers, failing the demo for a mechanical reason the spec hasn't addressed. Fix (pick one): (a) Process B is a real subprocess (`bun run` of a tiny fixture entry or the CLI) that imports the edited index fresh; or (b) the fixture repo's config lists the written tool file under `config.plugins` — the loader dynamic-imports at construction (`src/plugins/loader.ts:80-100`), so a fresh process picks it up with zero extra machinery. (b) is cheaper and keeps the fixture off the real checkout; state which, and that the e2e asserts via subprocess only.
26
+
27
+ ## Non-blocking observations
28
+
29
+ - Loop-test compatibility verified: passing a per-run ToolContext from `run_tool_calls` breaks nothing — stubs ignore the third arg (`test/loop.test.ts`); `e2e.test.ts` goes through the real executor.
30
+ - `git add`/`git commit` as two steps commits the WHOLE index: pre-existing staged changes ride along (v1 refused them). Prefer `git commit --only -m <msg> -- <paths>`; the seal's temp-index hash then covers exactly the committed tree.
31
+ - Terminal denylist regex is feasible: `before_tool_call` receives full `args`, so `args.command` is inspectable (`src/plugins/hooks.ts:89-97`).
32
+ - `GIT_CONFIG_GLOBAL=/dev/null` + `GIT_CONFIG_NOSYSTEM=1` is sufficient isolation; add explicit `-c user.email=... -c user.name=...` on commit so no identity fallback is needed.
33
+ - e2e wall clock: even with the mocked subprocess runner, budget `max_turns` (6-8 turns) is fine; set the e2e `testTimeout` ≥ 5 min for NFS git.
34
+ - Fail-closed registration (no gatekeeper → no commit tool) is the strongest design change in v2; keep `tools_enabled` filtering as the second layer.
35
+ - Q6: 1 is right; Q7: hardcoded v1; Q8: explicit call only — all three defaults are correct.
36
+ - `run_tests` filter arg should map to the vitest file-filter position, and the "max 10 failing names" clamp should come from `--reporter=dot`-free default output parsing, not JSON.
@@ -0,0 +1,69 @@
1
+ # Council Review R3 — Architecture & Testability
2
+
3
+ Reviewer: Council Member 3 (architecture) — plan: `docs/design/self-improvement-loop.md` v3
4
+
5
+ ## Verdict
6
+
7
+ APPROVE-WITH-CHANGES. All four round-2 blockers are resolved and both open confirmations are
8
+ sound; the remaining changes are small but real — adopt the security chair's scoped-add
9
+ amendment (delta 8's "no separate add" breaks the demo's own happy path), and resolve the
10
+ unborn-HEAD/assertion contradiction and the fixture test-command dependency (findings 1, 4).
11
+
12
+ ## Round-2 blockers status
13
+
14
+ 1. Seal mechanism — RESOLVED. Dirty flag adopted; my corrected temp-index mechanism is
15
+ recorded verbatim as the upgrade path ("hash the worktree, never the index").
16
+ 2. Core-plugin config plumbing — RESOLVED. Agent-constructed factory fits the existing
17
+ seams (finding 2); tool knobs ride the ExecutorDefaults.env precedent
18
+ (`src/agent/agent.ts:112-115`, env literal at 114) via ToolContext.env.
19
+ 3. run_tests cwd — RESOLVED. cwd = context.work_dir; the threading point is the bare
20
+ `deps.tools.execute(call.name, call.args)` in run_tool_calls (`src/agent/loop.ts:85-107`,
21
+ execute at 94); a supplied context cleanly supersedes defaults (`src/tools/executor.ts:42`).
22
+ 4. Process B — RESOLVED. Real bun subprocess; load_plugins resolves relative entries against
23
+ config.work_dir (`agent.ts:210` → `loader.ts:63`) and imports by file URL
24
+ (`loader.ts:67`) — no stale module cache; work_dir-relative fixture paths resolve.
25
+
26
+ ## Ruling on S-1 (dirty flag now, your chair's temp-index mechanism as recorded upgrade path) and the symbol-keyed state channel
27
+
28
+ ACCEPT-WITH-NOTES. S-1 is clean — the attestation states the terminal floor honestly, and
29
+ the upgrade path is mechanically correct as recorded (read-tree HEAD, not the live index,
30
+ is the right base — it matches --only's HEAD-plus-named-paths semantics exactly). The
31
+ channel is implementable without breaking consumers: HookContext stays { work_dir }
32
+ (`src/plugins/types.ts:11-13`), third-party signatures unchanged; hooks.ts swaps its
33
+ pick_defined flattening (`src/plugins/hooks.ts:52-58`) for per-plugin entries, each handed
34
+ a ctx exposing ONLY its own sub-map via the symbol (a shared bag reopens the forgery
35
+ channel via getOwnPropertySymbols). BOTH ctx build sites must share one per-run bag —
36
+ the bare lifecycle ctx (`agent.ts:168,177`) and the per-tool ctx (`hooks.ts:90`) — with
37
+ the per-run reset in call_run_start (`hooks.ts:108-116`). ~15 lines holds.
38
+
39
+ ## New findings in v3
40
+
41
+ 1. `git commit --only` rejects untracked paths (git 2.55.0 /tmp scratch: pathspec error,
42
+ exit 1) — delta 8's "no separate add" breaks the demo, where every committed file is
43
+ new. Security r3's fix verified: scoped `git add -- <named paths>` then `--only`
44
+ commits exactly those paths (worktree content), leaves pre-existing staged junk.txt
45
+ staged, and works on unborn HEAD; the commits counter is safe (increments on success
46
+ only; a failed commit leaves just the scoped adds staged). Required, same cluster:
47
+ "refuse unreachable HEAD" and "git log shows exactly 1 commit" cannot both hold —
48
+ unseeded fixture means unborn HEAD (a natural rev-parse guard vetoes the demo's own
49
+ commit); seeded means 2 commits. Pick one: seed a harness root commit and assert one
50
+ commit on top of it, or let the guard permit unborn HEAD.
51
+ 2. Loader hard-reject seam: insert beside the existing duplicate-name error entry
52
+ (`loader.ts:93-96`); the loader has no builtin-name knowledge today (delta 4's
53
+ "replaces warn-and-keep-first" mischaracterizes — that is config-vs-config behavior),
54
+ so either import a BUILTIN_PLUGIN_NAMES const (cycle-free) or thread a reserved-names
55
+ arg into load_plugins; the reject flows through the existing error-entry path, so
56
+ fail-closed holds. Agent wiring: construct once and push as a synthetic LoadedPlugin
57
+ through the unchanged register_plugin_tools/merge_plugin_hooks (`agent.ts:72-84,86-95`).
58
+ 3. Per-run ToolContext env completeness: a supplied context supersedes ExecutorDefaults
59
+ entirely (`executor.ts:42`), so the loop-built context must carry the same env the
60
+ executor defaults get (`agent.ts:112-115`) — LICH_TERMINAL_TIMEOUT_MS plus
61
+ LICH_TEST_COMMAND — or both knobs silently drop; build it once in Agent and hand it
62
+ over via loop_deps (stub runners ignore the third arg; `loop.ts:27-29`).
63
+ 4. Fixture dependency hole: the /tmp fixture has no node_modules, so the default
64
+ `node node_modules/vitest/vitest.mjs run` cannot resolve there; the e2e must export
65
+ LICH_TEST_COMMAND as a dependency-free runner (e.g. `bun test`) and the fixture test
66
+ must target that runner's imports.
67
+ 5. Checked and clean: mutex + `run_tests_busy` fit error_result conventions; terminal
68
+ `timeout_ms: 300000` is a constant swap inside with_timeout (`executor.ts:57-66`);
69
+ skills fresh-walk avoids the single-slot docs_search memoization trap.
@@ -0,0 +1,15 @@
1
+ # Design council — self-improvement loop reviews
2
+
3
+ Adversarial review record for the v0.4.0 self-improvement loop design
4
+ ([final spec](../self-improvement-loop.md)). Three chairs, three rounds each.
5
+ The declared convergence rule: all APPROVE, or AWC with only minor notes.
6
+
7
+ | Chair | Round 1 | Round 2 | Round 3 |
8
+ | --- | --- | --- | --- |
9
+ | Security | [REJECT](security-review.md) | [AWC](security-review-r2.md) | [APPROVE](security-review-r3.md) |
10
+ | Simplicity | [AWC](simplicity-review.md) | [AWC](simplicity-review-r2.md) | [APPROVE](simplicity-review-r3.md) |
11
+ | Architecture | [AWC](architecture-review.md) | [AWC](architecture-review-r2.md) | [AWC — minor notes](architecture-review-r3.md) |
12
+
13
+ Round-3 notes were folded into the final spec as amendments A1–A11. Read the
14
+ r3 files for implementation guards; earlier rounds explain the reasoning
15
+ behind each ruling.
@@ -0,0 +1,32 @@
1
+ # Council Review R2 — Security & Safety
2
+
3
+ ## Verdict
4
+
5
+ **APPROVE-WITH-CHANGES.** v2 genuinely fixes the systemic holes — prompt-injection surfaces cut, no LLM writes durable state, the TOCTOU seal is the right mechanism and maps onto real seams (hooks.ts:88-96 verdict path, planned `run_tool_calls` context plumbing) — and the terminal floor is honestly documented rather than hidden. But the gatekeeper's fail-closed claim still fails against the agent-writable `.lich/config.json` (displace or spoof the gatekeeper via the plugins list), and the shared `run_state` Map lets any plugin tool forge the seal; both must be closed before implementation.
6
+
7
+ ## Round-1 findings status
8
+
9
+ 1. Terminal bypass — **PARTIALLY RESOLVED**: plumbing-based `git_commit` + tree-hash seal is real; raw `terminal` git remains and v2 states it as the floor, but the denylist is weaker than acknowledged (`git commit-tree`+`update-ref` plumbing misses `/git\s+(commit|push|remote)/` entirely — see NB-1).
10
+ 2. Fail-open gatekeeper — **PARTIALLY RESOLVED**: load *failure* now means no commit tool, but load *success* of a malicious entry is unaddressed; config plugins list is agent-editable (NB-2/finding 1).
11
+ 3. TOCTOU seal — **RESOLVED**: seal captures HEAD+index-hash at test time, verified at commit; tests→edit→commit now vetoed. Verified the veto point exists (hooks.ts:88-96) and per-run ToolContext plumbing is planned for the missing channel.
12
+ 4. Memory in system prompt — **RESOLVED**: cut; MEMORY.md is committed, human-reviewable, never auto-loaded.
13
+ 5. Skills DATA/instructions — **PARTIALLY RESOLVED**: skills toolset cut; `docs_search` over `.lich/skills` still returns raw untrusted text, auto-load is gone, and the executor's 20k clamp (guard.ts:100) is the only boundary — the "tool results are untrusted data" rule is still asserted in prose, not stated in the system prompt or docs as a standing control.
14
+ 6. Reflector LLM writes — **RESOLVED**: consolidation cut entirely; no LLM output becomes durable state.
15
+ 7. Plugin tools bypass `tools_enabled` — **RESOLVED**: allowlist extended to plugin tools.
16
+ 8. Concurrent memory writes — **RESOLVED**: single writer (agent appends via edit_file), no consolidation; cross-run append races are now cosmetic.
17
+
18
+ ## New blocking findings in v2
19
+
20
+ 1. **Config/plugin injection still defeats "fail-closed"** — HIGH. `.lich/config.json` lives in work_dir and the agent edits files freely (`write_file`/`edit_file`, work_dir-confined but inclusive). It can add `plugins: [".lich/evil.plugin.js"]`, or reorder/remove entries; loader dedupes by name keeping the FIRST load and turns the later duplicate into a warning (loader.ts:97-99), and config order decides who loads first. A plugin exporting `name: "gatekeeper", tools: [git_commit_tool]` (importing the builtin) therefore shadows the real one with an always-pass gatekeeper; the v2 invariant only covers *load failure*, not *malicious load success*. Fix: register the gatekeeper in code unconditionally (never via the config plugins list), reject any config-supplied plugin whose name collides with a builtin, and ideally pin `allow_self_commit`/`test_command` outside the agent-writable config (round-1 fix, still not adopted). Tension: hardcoding contradicts the loader's user-configurability — accept it for safety-critical plugins.
21
+ 2. **Shared `run_state` Map is a seal-forgery channel** — MED/HIGH. `HookContext.run_state: Map<string, unknown>` is one map handed to every hook and (via ToolContext) every tool; any plugin-supplied tool — or any code path a poisoned doc steers a plugin author toward — can `run_state.set()` the gatekeeper's keys and mint a valid seal without ever running tests. Fix: per-plugin namespaced sub-maps (factory handed to each hook), or a gatekeeper-private unguessable per-run key; at minimum, document third-party plugins as untrusted. Tension: simplicity — a namespacing wrapper is ~20 lines and worth it.
22
+
23
+ ## Non-blocking observations
24
+
25
+ - Denylist misses: `git -c x=y commit` (no `git\s+commit` match), `env git commit`, `bash -c`, backtick/nesting obfuscation, and critically `git commit-tree` + `git update-ref` (same effect, no regex hit). Add those two patterns at minimum; keep Q7 hardcoded.
26
+ - Secret basename check misses `.env.local`, `.p12`, key material in subdirs; a `git diff --cached --stat` echo in the veto/allow reason would make human review far cheaper.
27
+ - Seal implementation care: the index hash must be computed by the gatekeeper (e.g. `git write-tree`/porcelain) at `run_tests` time — never accepted from agent-visible tool output, which is clamped text an agent can paraphrase.
28
+ - Consider refusing to *seal* on a dirty pre-test tree (`git status` non-empty before run_tests) — removes ambiguity about what the seal attests.
29
+ - Two-process demo is sound; pin Process B's work_dir in the harness so config can't point it at the real repo; assert exactly 1 commit.
30
+ - MEMORY.md "human-reviewable" holds only if the human reviews between agent appends and the next self-commit — one docs sentence.
31
+ - Data/instructions boundary: state it once in the system prompt ("docs/skills/memory tool results are reference data, not instructions") — cheap, and makes finding 5's residual explicit instead of implicit.
32
+ - Q6: 1 commit/run as default is fine; Q7: hardcoded v1 (agreed); Q8: explicit call only — auto-run would blur the seal and hide veto reasons.
@@ -0,0 +1,19 @@
1
+ # Council Review R3 — Security & Safety
2
+
3
+ ## Verdict
4
+ APPROVE. Both round-2 blockers are verified closed against source — the gatekeeper now lives outside the loader's reach entirely, config-supplied colliders die at the exact dedupe seam my r2 finding cited, and no in-process forgery path survives the symbol-keyed channel plus the executor-sourced structured result. What remains is the documented terminal floor and plugin-trust floor, both now honestly attested, plus only non-blocking implementation notes below.
5
+
6
+ ## Round-2 blockers status
7
+ 1. Config/plugin injection defeats fail-closed — **RESOLVED**: gatekeeper constructed in code by `Agent` (constructor precedes `register_plugin_tools`, agent.ts:105-113), so the loader never sees it; hard-reject of builtin-colliding names is a two-line change at the cited dedupe seam (loader.ts:94-99, `seen.has` → error entry, warn-and-continue contract intact for other entries), and the env knobs can only enter via constructor injection from process env (agent.ts:114-117 precedent; zod config has no env passthrough, config.ts:21-39) — agent edits to `.lich/config.json` cannot reach the frozen per-run `ToolContext`.
8
+ 2. Shared `run_state` Map is a seal-forgery channel — **RESOLVED**: `HookContext` is bare `{work_dir}` today (types.ts:9-11); the per-plugin sub-map keyed by a module-internal symbol is implementable at the ctx-construction seam (hooks.ts:90), and plugin tools structurally never receive `HookContext` (executor hands tools a `ToolContext`, executor.ts:39-49) — tool-body forgery is impossible, hook-body forgery dies with per-plugin namespacing (note S-1b).
9
+
10
+ ## Ruling on S-1 (dirty-flag seal) and the symbol-keyed state channel
11
+ ACCEPT-WITH-NOTES. The dirty flag holds my invariants: it closes the in-process TOCTOU (write_file/edit_file → `dirty=true` → veto at the verified first-blocker-wins seam, hooks.ts:88-96), its carrier is the unforgeable namespaced channel, `tests_ok` derives from the executor's structured result (the agent never asserts it), and the attestation statement scopes the residual to the documented terminal floor — the honest-floor framing I demanded in r1/r2, with architecture's worktree-hash upgrade path recorded so the git mechanics are never re-derived wrong. Two notes, both spec-committed requirements with verified seams, listed so they cannot be implemented wrong:
12
+ (a) "structured ok" requires extending `AfterToolCallInfo` with the executor's `ok` boolean — today it carries only 300-char clamped `result_summary` text (hooks.ts:23, 99-101; types.ts:25-27); two lines at hooks.ts:99-102; do not ship text-prefix parsing.
13
+ (b) the per-plugin sub-map must be attached per hook invocation (each hook sees its own wrapped ctx), not one shared symbol→plugin-map on a single ctx — `Object.getOwnPropertySymbols(ctx)` probing reaches a shared channel; write the listed sub-map-isolation unit test probe-aware.
14
+
15
+ ## New findings in v3
16
+ 1. NOTE (non-blocking) — registration order is load-bearing: the gatekeeper's `git_commit` must register before `register_plugin_tools` (agent.ts:113) so the existing duplicate check (agent.ts:75-78) makes first-wins favor it; add one unit test — config plugin exporting a tool named `git_commit` while the gatekeeper is active → gatekeeper wins.
17
+ 2. NOTE (non-blocking) — `git commit --only` rejects untracked paths ("pathspec ... did not match any file(s) known to git"), and the demo commits NEW files; fix is a scoped `git add -- <paths>` (exactly the named paths) before `--only`, which preserves the no-sweep property; failure direction is fail-closed and the e2e will catch it if missed.
18
+ 3. NOTE (non-blocking) — "no gatekeeper → no `git_commit` anywhere" is true of the trusted registration path only: with the gatekeeper off, a config plugin may still register a tool named `git_commit` — capability-equivalent to the documented plugin-trust floor (arbitrary startup code, human reviews config diffs), no new capability; scope the docs sentence accordingly.
19
+ 4. NOTE (non-blocking) — `paths` arg validation: `resolve_safe_path(base, "")` returns base (guard.ts:12-19 accepts it), so `paths:[""]`/`["."]` would pathspec-commit all tracked modifications; require non-empty relative file-like paths. Also checked clean: denylist delta 9 (commit-tree/update-ref any-occurrence, flag-tolerant) matches my r2 NB; per-run state reset fires via `on_run_start` per `Agent.run` (agent.ts:129, hooks.ts:106-118); run_tests mutex and busy error are fail-closed; secret blocklist covers my r2 list; the r2 allowlist-extension to plugin tools survives v3's rewiring.
@@ -0,0 +1,35 @@
1
+ # Council Review R2 — Simplicity & Value
2
+
3
+ ## Verdict
4
+
5
+ APPROVE-WITH-CHANGES — All four round-1 cuts landed and the scope is now close to the minimal loop. Two pieces of fresh gold-plating remain: the HEAD+index-hash seal (over-complex, and as specified it doesn't even do what it claims) and the `docs_roots` config knob (a hardcoded constant suffices).
6
+
7
+ ## Round-1 findings status
8
+
9
+ 1. Skills tools (rebadged docs tools) — RESOLVED: skills are `write_file` + `docs_search`; residual `docs_roots` config flagged below.
10
+ 2. LLM consolidation/reflector — RESOLVED: cut entirely; agent appends its own dated lines; zero LLM pipelines.
11
+ 3. `repo_status` — RESOLVED: cut; documented `terminal git status` recipe.
12
+ 4. Milestone collapse — RESOLVED: single v0.4.0 release; the e2e demo is the acceptance test.
13
+ 5. `git_commit` + gatekeeper (kept) — RESOLVED: kept as the named choke point; see finding 1 on the seal replacement.
14
+
15
+ ## New blocking findings in v2
16
+
17
+ 1. **HEAD + index-hash seal — high — gold-plated and mis-specified.** (a) As written it doesn't work: `write_file`/`edit_file` edits never touch the git index (only `git add` does), so "tests → edit → commit" still passes an index-hash check; catching it requires hashing the tracked *worktree* (`git stash create`-style plumbing). (b) The gatekeeper would shell out to git from inside an `after_tool_call` hook to capture state — new subprocess failure modes on NFS (this user's actual mount) for marginal value. Simpler, equally safe for v1: naive run_state dirty tracking — `on_run_start` sets `tests_ok=false`; `after_tool_call` on any file-writing tool (`write_file`, `edit_file`) sets `dirty=true`; a green `run_tests` clears both; veto `git_commit` unless `!dirty && tests_ok`. ~20 lines inside the gatekeeper, no git plumbing, and it closes the primary TOCTOU (the agent's own edit-after-test path). The residual gap — edits via raw `terminal` — is already inside v2's documented honest floor. If a state hash is ever revisited, hash worktree state, not the index, and say so precisely.
18
+
19
+ 2. **`docs_roots` config knob — medium.** Cost: a new zod key, ordered multi-root semantics, per-root cache invalidation, plus a hidden wrinkle v2 doesn't mention — `dir_with_index` in `src/tools/builtin/docs_read.ts` requires an `index.md`, which `.lich/skills/` will never have, so the existing validator rejects the skills root outright. And the justification leans on "makes plugin-bundled docs work" — solving an unrequested second problem to pay for the mechanism. Simpler: hardcode it — append `<work_dir>/.lich/skills` to the candidate chain in `default_resolve_docs_root` (relaxing the index.md gate for that candidate) and have `docs_search` merge the skills dir as a second scored source. A couple of constants, no config, no docs page. Promote to config only when a second consumer appears.
20
+
21
+ ## Non-blocking observations
22
+
23
+ - `max_commits_per_run` became config-tunable (min/max/zod/docs) — my Q3 answer was "hardcode 1 until proven wrong." Revert.
24
+ - Terminal denylist: worth keeping only because it stays hardcoded (Q7's default proposal is correct; no `deny_command_patterns` config in v1). Drop `remote` from the regex — `git remote -v` is harmless recon the agent legitimately uses; `commit|push` are the real bypasses.
25
+ - Per-tool `timeout_ms`: approved as the minimal form of a necessary change — an optional field defaulting to today's 30s executor cap beats name-based special-casing in the executor; only `run_tests` sets 600000.
26
+ - Ship `git_commit` as the gatekeeper plugin's own `tools` entry (loader already supports this — `src/plugins/loader.ts`, `docs/user-guide/plugins.md`) instead of inventing an auto-loaded "builtin plugins" concept for `src/plugins/builtin/`; no gatekeeper listed → literally no `git_commit` tool.
27
+ - e2e negative paths (commit-before-tests, edit-without-retest) belong in gatekeeper unit tests; keep the two-process e2e to the happy path. Process B (fresh-load verification) is justified — it's the half that honestly proves the module-cache reality.
28
+ - Secret-basename blocklist and the `run_tests` concurrency mutex: small, hardcoded, motivated by the real NFS environment — fine.
29
+ - Conventions: the dirty-tracking gatekeeper fits the 60-line hook rule with room to spare; no recursion; zero new dependencies. The seal version strained the line budget and added git subprocess calls inside hooks — one more reason to cut it.
30
+ - Q8: explicit `run_tests` call only — auto-run-before-commit is a magic dependency chain that makes veto behavior harder to reason about.
31
+ - What I checked for new bloat and cleared: `git_commit` arg guards (paths 1..50, secret-ish names, HEAD check), fail-closed registration, run_state plumbing (also fixes the real "no context passed today" bug), memory/skills as conventions-not-machinery, single-milestone plan, NFS retry notes.
32
+
33
+ ## Minimal version check
34
+
35
+ v2 matches round-1's minimal version in shape — 2 tools, 1 plugin, 0 skill tools, 0 memory machinery, 1 milestone — plus two justified additions my r1 list lacked: per-run `ToolContext`/`run_state` plumbing and the two-process e2e. Residual cuts to converge: (1) seal → dirty flag, (2) `docs_roots` → hardcoded resolver candidate + merged skills source, (3) `max_commits_per_run` → hardcoded 1, (4) denylist regex minus `remote`. With those four, v2.1 is exactly the r1 minimal version.
@@ -0,0 +1,22 @@
1
+ # Council Review R3 — Simplicity & Value
2
+
3
+ ## Verdict
4
+ APPROVE — All four round-2 cuts landed as specced, and every surviving v3 addition (env-channel knobs, loader collision check, symbol state channel, `git commit --only`) is either reviewer-mandated or a net simplification. What remains are four non-blocking notes on spec precision, none of which add machinery.
5
+
6
+ ## Round-2 blockers status
7
+ 1. Seal → dirty flag — RESOLVED (ruling S-1: no git subprocess in hooks; attestation sentence stated, not implied; upgrade path recorded so the git mechanics are never re-derived wrong).
8
+ 2. `docs_roots` → hardcoded resolver candidate — RESOLVED (delta 2: no zod, index.md gate relaxed for that candidate, fresh-walk only for the user-writable root; package docs keep their cache).
9
+ 3. `max_commits_per_run` → hardcoded 1 — RESOLVED (`commits < 1` in the veto condition; zero knobs).
10
+ 4. Denylist minus `remote` — RESOLVED (delta 9: stays hardcoded, gains only flag-tolerance + `commit-tree`/`update-ref` — correct hardening, one alternation).
11
+
12
+ ## Ruling on S-1 (your chair's dirty-flag proposal) and the symbol-keyed state channel
13
+ ACCEPT-WITH-NOTES — S-1 is the r2 mechanism adopted whole (structured-`ok` gate, no agent assertion, honest floor documented). The symbol channel is within budget: ~15 lines in `plugins/hooks.ts`, and it *removes* a required "trust third-party plugins" caveat rather than adding a concept — the fallback (per-plugin string sub-maps + documented distrust) pays a documentation convention to keep open a hole 15 lines close; it loses. Notes: implement as a module-internal WeakMap keyed on the plugin object (same line count, no symbol property to enumerate), keep `with_hook_state`/`hook_state_for` unexported, never generalize into a public state API. The specced gatekeeper itself (state init + 3 after-hook cases + 2 veto cases + regex) is ~35-40 lines — inside the 60-line house rule with room.
14
+
15
+ ## New findings in v3
16
+ 1. Pin the env sourcing in one sentence: the loop-fix bullet says per-run ToolContext is "work_dir + env from config" while `LICH_TEST_COMMAND`/`LICH_ALLOW_SELF_COMMIT` must originate from `process.env` — state that the env map is assembled in code (config-derived `LICH_TERMINAL_TIMEOUT_MS` + process-env knobs) and never gains a config passthrough. The knobs themselves are acceptable, not bloat: zero zod, unset→false fail-closed, and the agent cannot mutate its parent's env, so no runtime override exists; the e2e fixture repo also needs `LICH_TEST_COMMAND`. Stale cite: the env channel is `src/agent/agent.ts:114` (file is 84 lines, not 110-113).
17
+ 2. "Gatekeeper reads the structured `run_tests` result" needs its additive hook change spelled out: `AfterToolCallInfo` today carries only a 300-char `result_summary` (clamp in `plugins/hooks.ts`), so structured gating requires extending after-hook info with `ok`/`error` (~3 additive lines). Specify that — not summary-string parsing, which is the brittle transcript-matching r1 rejected.
18
+ 3. Loader collision check is smaller than delta 4 implies: current code already hard-rejects duplicate names among *config* plugins (`loader.ts:91-95` `seen` set → `duplicate_plugin_name` error); the only new code is a builtin-names membership test. Keep it a hardcoded frozen list — no registry machinery.
19
+ 4. Checked for smuggled machinery and cleared: `git commit --only` (removes a separate staging step — net simpler); two-process e2e (r2-endorsed Process B; negative paths already moved to gatekeeper unit tests per my r2 note); fresh-walk skills dir (the agent writes a skill then must find it — a cache would break the loop's own demo); boundary sentence, secret blocklist, busy mutex, terminal 300s timeout (r2-cleared or one-liners). No zod keys anywhere in the final spec.
20
+
21
+ ## Minimal version check
22
+ v3 is the minimal version: 2 tools, 1 plugin, 0 skill tools, 0 memory machinery, 1 milestone — every surviving line maps to an r1/r2-cleared necessity or a reviewer-mandated security fix; ship it.