Authored: 2026-05-13 by the Phase 1 implementing agent. Acceptance details in phase-1-acceptance.md. All 94 tests pass (72 core + 7 state-zustand + 8 palette-react + 7 example integration), all four packages typecheck and build, the graph-editor's Vite dev server serves the app and its production bundle is ~107 KB gzip.
This file answers the six questions from docs/implementation_plan.md §"Phase 1 → Pre-next-phase reflection checklist".
Yes, with two observations:
Fields I reached for and used: id, title, description, category, keybinding, params, when, tier, execute. That is the canonical use-set for any non-trivial command. The graph-editor has six param-free commands + one parameterized + one parameterized helper (app.selection.set) + one internal reset (app.dev.resetState) and they fit into the shape without friction.
Fields I did NOT use in Phase 1:
aliases,icon,defaultScore,follow— never reached for. The graph editor isn't large enough to need them. They're cheap to leave in the surface as Phase 2 inputs and have known consumers (aliasesfor palette ranking,iconfor VS Code-style affordances).kind— the field exists but Phase 1's palette ignores it (parameter-free commands are implicitly atomic). Phase 2 will exercise it via the picker chain. Leave as-is.
Fields I wanted to add but resisted: none in Phase 1. Closed-surface discipline held: any "I wish there were a confirmBeforeExecute field" temptation was correctly defused by realizing confirmation is a middleware concern.
One subtle finding that should propagate to the docs: CommandRecord<P, R> with AnyCommandRecord = CommandRecord<unknown, unknown> causes contravariance pain when a typed CommandRecord<{x:number}, string> is passed to registerAll(cmds: AnyCommandRecord[]). I switched AnyCommandRecord to CommandRecord<any, any> (with a comment explaining why). The user-facing types stay precise (defineCommand<P, R> still infers P, R); only the erased-storage type uses any. This is a TypeScript-variance concession, not a design change. Documented in types.ts.
Recommendation for Phase 2: no changes to the record shape. The auto-derived kind heuristic happens at palette time, not at record-definition time, so it doesn't grow the surface.
Yes. The dual-form setState(updater: (s) => S | void) works for both Immer-style mutate-the-draft (s.count = 42) and plain return-a-new-object ({ ...s, count: 42 }) inputs. I verified both paths in packages/state-zustand/src/index.test.ts > accepts mutate-the-draft (returns void) updaters.
The friction I expected but didn't hit: wrapping zustand/vanilla's native subscribe(listener: (state, previousState) => void) against acture's subscribe(listener: (next, prev) => void). Zustand already passes both arguments natively; the adapter just forwards them. RTK won't be able to pass previous cleanly (it's not tracked) — the StateAdapter contract documents that adapters that can't track previous should pass the same value as current. That's still acceptable; Phase 2 will confirm with the RTK adapter.
Patches-capable extension was clean. setStateWithPatches wraps produceWithPatches from immer; the resulting Immer patches are mapped to acture's Patch type (which is RFC-6902-shaped). applyPatches does the reverse mapping and feeds Immer's applyPatches. The integration test that round-trips through inverse patches passes — this is the load-bearing capability for the future acture-undo.
Recommendation for Phase 2:
- Pin the
Patchop enum to'add' | 'remove' | 'replace'only for now. Don't add'copy'/'move'until a real caller asks. Immer does not produce them; only mobx-state-tree'sonPatchdoes, and acture's undo design doesn't need them. - Verify the RTK adapter's
subscribesemantics match the contract; if RTK can passpreviousviastore.subscribe(prev => …)-style middleware, do so; otherwise document the limitation.
LOC: 503 in when.ts (including comments, AST types, the tokenizer, the recursive-descent parser, the evaluator, and three public-API helpers). The 200-LOC threshold in the reflection prompt was overshot.
Where the LOC actually goes:
- ~70 lines of comments/grammar documentation.
- ~180 lines of tokenizer (string/regex literals are the longest cases — escape handling).
- ~80 lines of parser.
- ~50 lines of evaluator + helpers.
- ~120 lines of public-API surface + types.
Is it worth it? Yes, on three grounds:
- Serializability to AI/MCP/external surfaces. A DSL string can be projected into a tool description; a function escape hatch cannot. The function form is flagged "not exposable" so consumer projections can omit such commands or warn. If acture were function-only, every command with
when:would be opaque to AI/MCP, defeating one of the eight consumer surfaces. - Inspectable / debuggable predicates. Phase 4's devtools UI can render the AST and the live evaluation. A function can only be displayed as
<function>. - Onboarding cost for command authors. Writing
when: 'selection.length >= 1'is shorter and clearer thanwhen: (ctx) => (ctx.selection?.length ?? 0) >= 1.
However, the LOC is high. If Phase 2 / 3 grows the grammar (e.g., adding >/< after the locked operator review, or array literals as RHS of in), the hand-rolled parser will grow further. At ~600 LOC I would reconsider a parser-combinator approach. For Phase 1, hand-rolled with no dep was the right call.
Recommendation for Phase 2 / 4: Re-survey the locked operator set when implementing devtools. The bare-> rejection (the user-facing error says "use >=") may surprise authors. If three authors hit it, add > and < (this is an additive change, not breaking).
Yes — four gaps, none blocking. The second agent succeeded but had to peek at examples/greenfield/graph-editor/src/commands/index.ts to learn the wiring pattern. Full verbatim list in phase-1-acceptance.md §4. Summarized:
buildCommands(state: StateAdapter)factory pattern is undocumented. Both the core README and the graph-editor README skip how the example wires state into the commands module. The agent expected either a module-level constant array or commands taking state viactx— neither is right.- Graph-editor state shape is undocumented. A contributor adding a command that mutates state has to read
state.tsto learn the field names. noUncheckedIndexedAccessis undocumented. The core README'srenameNodesketch showsdraft.nodes[id].label = …, which the agent correctly noted would fail under acture's strict TS settings. The agent had to use an if-guarded form to get past the typecheck.state.getState()as the idiomatic read primitive is not called out in the StateAdapter section.
Action for Phase 2:
- Add a "How to add a command (in this example)" section to the graph-editor README that names the factory pattern and shows the state shape with a code excerpt.
- Add a note in the core README's "Writing a new command — pattern" that real code typically closes over a
StateAdapter<S>from a host module, and link to the example. - Add a
noUncheckedIndexedAccess-friendly version of therenameNodesketch. - Add a "read with
getState(), mutate withsetStateWithPatches" one-liner.
Recommendation: make these doc additions a Phase 2 deliverable; do not block Phase 1.
Ran the merge checklist from .claude/skills/acture-hard-donts/SKILL.md. Going through them:
- No conditional logic in command metadata. ✅ The
whenfield is the upper bound; we did not grow callback variants à la Obsidian. - No god-package. ✅ Core has zero React, zero state-library, zero UI deps. The three packages (
acture,acture-state-zustand,acture-palette-react) are tightly scoped. - No business logic in adapter packages. ✅
acture-state-zustandonly translates between zustand+immer and theStateAdapter<S>contract.acture-palette-reactonly iterates the registry and renders cmdk items — even the parameterized-command "Phase 2 badge" is a rendering choice, not business logic. - No
if (mode === ...)in shared helpers. ✅ Core has no mode awareness. - No
eval()-ing LLM strings. ✅ The dispatcher takes(id, args)and usesMap.get(id). No reflective call. - No coupling the registry to React. ✅ The
createRegistryis plain TS. React lives only inacture-palette-react(its hook + JSX). The example'suseGraphStateis in the example, not in core. - No promoting
@experimentalwithout a migration story. N/A (no experimental→stable promotions yet). - No bundling a UI kit. ✅
acture-palette-reactuses cmdk's headless primitives; styling is the host's job, demonstrated in the example with a small CSS file. - No marketing on category. ✅ READMEs lead with "One schema. Palette, hotkeys, AI tools, MCP, and tests — for free."
- No LLM-as-authorization. ✅ Schema validation is at the dispatcher (
schema.safeParse(params)) beforeexecuteruns.
One borderline call: examples/greenfield/graph-editor/src/registry.ts constructs the registry at module-load time and exports a singleton. This is fine for an example, but should not be promoted as the recommended pattern for production — there are cases (multi-window, hot-reload, server-rendered) where lazy construction is needed. The graph-editor README does not promote it as canonical; it just demonstrates a working setup.
None blocking. The four escalations from Phase 0's handoff doc were resolved at the start of this session via AskUserQuestion:
- Branching: Phase 0 committed to
main, Phase 1 onphase-1branch (PR-style). - Subpackage naming:
acture-<name>scoped pattern (acture-state-zustand,acture-palette-react). - Schema layer: Zod-only for Phase 1. Standard Schema acceptance lands in Phase 2 without breaking Zod authors (additive).
- When-clause parser: hand-rolled recursive descent. Confirmed in §3 above as the right call for Phase 1.
Non-blocking observations the user may want to weigh in on later:
patches/effectsreserved hooks survived Phase 1. Phase 2 should not consume them; Phase 4+ may. If the user wants to push undo into v1.0 instead of post-v1, the hooks are ready. (Recommendation: stay on post-v1 trajectory; no concrete need has surfaced yet.)- The internal
app.dev.resetStatecommand is a pragmatic test affordance, not a load-bearing pattern. Phase 3's migration package or Phase 4's devtools may want a more principled "snapshot/restore" mechanism. Not urgent. - The
Patch.openum is currently'add' | 'remove' | 'replace'— RFC 6902 also has'copy','move','test'. Immer doesn't produce them. Don't grow the enum until someone asks. (See also §2.) - The graph-editor's palette overlay closes on parameterized-command select. This is a Phase-2 stand-in. Phase 2 will replace it with the picker chain; the host doesn't need to change anything except register the new palette behavior.
Per docs/implementation_plan.md §"Phase 2 — Adapter buildout" the next phase ships:
acture-hotkeys(tinykeys)- Extended
acture-palette-reactwith parameterized command support (research-2 §9) acture-forms-autoform+acture-forms-rjsfacture-state-redux(RTK reference adapter)acture-mcp-server(MCP server adapter)acture-ai-vercel(Vercel AI SDK adapter)- Extended worked examples + a new drop-in example
None of those are gated by a Phase 1 rethink. The CommandRecord shape, the StateAdapter contract, and the schema bridge are stable. Phase 1 is DONE.