diff --git a/.behavior/v2026_02_15.khlone-v0/1.vision.md b/.behavior/v2026_02_15.khlone-v0/1.vision.md index 33a06fb..6b2651e 100644 --- a/.behavior/v2026_02_15.khlone-v0/1.vision.md +++ b/.behavior/v2026_02_15.khlone-v0/1.vision.md @@ -125,7 +125,7 @@ crew: foreman: ehmpathy/foreman brains: # friendly aliases → brain slugs (resolved by rhachet) - claude: claude@anthropic/claude/code/opus/v4.5 + claude: claude@anthropic/claude/opus/v4.5 codex: codex@openai/codex/v5.3-with-thought grok: opencode@xai/grok/code-fast-1 gemini: gemini@google/gemini/code/v2 diff --git a/.behavior/v2026_02_15.khlone-v0/1.vision.zoomin.braincli-lookup.md b/.behavior/v2026_02_15.khlone-v0/1.vision.zoomin.braincli-lookup.md index a1241ff..a891fe5 100644 --- a/.behavior/v2026_02_15.khlone-v0/1.vision.zoomin.braincli-lookup.md +++ b/.behavior/v2026_02_15.khlone-v0/1.vision.zoomin.braincli-lookup.md @@ -39,7 +39,7 @@ $ khlone act "implement auth" 1. khlone detects no zone state for @feat/auth 2. khlone auto-inits zone 3. khlone looks up hero config → role: foreman, brain: claude -4. khlone looks up "claude" alias → claude@anthropic/claude/code/opus/v4.5 +4. khlone looks up "claude" alias → claude@anthropic/claude/opus/v4.5 5. khlone checks: is there an extant brainCli for foreman.1? → no 6. khlone calls rhachet to spawn a new brainCli from slug 7. rhachet returns brainCli instance (pid: 12345) @@ -87,7 +87,7 @@ $ khlone ask "research auth patterns" --who researcher++ 1. khlone parses --who researcher++ → enroll new researcher clone 2. khlone looks up "researcher" role alias → ehmpathy/researcher 3. khlone determines brain for new clone → hero default: claude -4. khlone looks up "claude" alias → claude@anthropic/claude/code/opus/v4.5 +4. khlone looks up "claude" alias → claude@anthropic/claude/opus/v4.5 5. khlone checks: is there an extant brainCli for researcher.1? → no (just enrolled) 6. khlone calls rhachet to spawn a new brainCli from slug 7. rhachet returns brainCli instance (pid: 12346) diff --git a/.behavior/v2026_02_15.khlone-v0/3.2.distill.domain._.v1.i1.md b/.behavior/v2026_02_15.khlone-v0/3.2.distill.domain._.v1.i1.md index 973d656..b1c968e 100644 --- a/.behavior/v2026_02_15.khlone-v0/3.2.distill.domain._.v1.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/3.2.distill.domain._.v1.i1.md @@ -91,7 +91,7 @@ interface Clone { role: string; // role alias (e.g., mechanic) roleSlug: string; // fully qualified (e.g., ehmpathy/mechanic) brain: string; // brain alias (e.g., claude) - brainSlug: string; // full brain slug (e.g., claude@anthropic/claude/code/opus/v4.5) + brainSlug: string; // full brain slug (e.g., claude@anthropic/claude/opus/v4.5) index: number; // instance index (the .n in mechanic.n) status: CloneStatus; pid: number | null; // brainCli process id (null if not spawned) diff --git a/.behavior/v2026_02_15.khlone-v0/3.4.blueprint.handoff.contract.braincli.v1.i1.md b/.behavior/v2026_02_15.khlone-v0/3.4.blueprint.handoff.contract.braincli.v1.i1.md index 756434c..f7e8b11 100644 --- a/.behavior/v2026_02_15.khlone-v0/3.4.blueprint.handoff.contract.braincli.v1.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/3.4.blueprint.handoff.contract.braincli.v1.i1.md @@ -23,7 +23,7 @@ khlone never spawns CLI binaries directly. it calls rhachet, and rhachet calls t ### 1.1 spawn a BrainCli from a brain slug -**the fundamental:** khlone holds a brain slug (e.g., `claude@anthropic/claude/code/opus/v4.5`). it needs to hand that slug to rhachet and get back a live BrainCli handle — a process that khlone's daemon can manage. +**the fundamental:** khlone holds a brain slug (e.g., `claude@anthropic/claude/opus/v4.5`). it needs to hand that slug to rhachet and get back a live BrainCli handle — a process that khlone's daemon can manage. **why:** every clone needs a CLI process. the daemon spawns one per clone. the slug comes from `khlone.yml` alias expansion (e.g., user writes `claude`, khlone expands to full slug, rhachet expands slug into a runnable CLI). @@ -240,7 +240,7 @@ these are preferences, not demands. rhachet should design its own contract — b * the returned handle is long-lived — persists across many ask/act calls. */ const brainCli = await genBrainCli({ - brainSlug: 'claude@anthropic/claude/code/opus/v4.5', + brainSlug: 'claude@anthropic/claude/opus/v4.5', cwd: '/home/vlad/git/ehmpathy/myrepo', role: 'ehmpathy/foreman', series: null, // null on first spawn; ref text on respawn @@ -349,7 +349,7 @@ these show exactly how khlone's daemon uses BrainCli at each touchpoint. // 1. spawn brainCli for the hero clone const brainCli = await genBrainCli({ - brainSlug: 'claude@anthropic/claude/code/opus/v4.5', + brainSlug: 'claude@anthropic/claude/opus/v4.5', cwd: '/home/vlad/git/ehmpathy/myrepo', role: 'ehmpathy/foreman', series: null, diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/0.wish.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/0.wish.md index 61e42bf..c874c34 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/0.wish.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/0.wish.md @@ -200,6 +200,6 @@ itll specify aliases for the brains too e.g., grok = xai/grok/code-fast-1 -claude = anthropic/claude/code/opus/v4.5 +claude = anthropic/claude/opus/v4.5 codex = openai/codex/v5.3 etc diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/1.vision.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/1.vision.md index 3079f76..0181acb 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/1.vision.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/1.vision.md @@ -121,7 +121,7 @@ crew: foreman: ehmpathy/foreman brains: # brain aliases for --brain - claude: anthropic/claude/code/opus/v4.5 + claude: anthropic/claude/opus/v4.5 grok: xai/grok/code-fast-1 codex: openai/codex/v5.3 gemini: google/gemini/code/v2 diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v1.i1.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v1.i1.md index 7a19cca..de74c78 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v1.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v1.i1.md @@ -795,7 +795,7 @@ interface Clone { role: string; // alias roleRef: string; // "ehmpathy/mechanic" brain: string; // alias - brainRef: string; // "anthropic/claude/code/opus/v4.5" + brainRef: string; // "anthropic/claude/opus/v4.5" status: 'idle' | 'active'; task: { focus: { slug: string; progress: number } | null; diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v2.i1.gaps.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v2.i1.gaps.md index ee01bb6..f0b6143 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v2.i1.gaps.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v2.i1.gaps.md @@ -241,8 +241,8 @@ class RateLimiter { **the assumption:** ```ts const BRAIN_ADAPTERS: Record Promise> = { - 'anthropic/claude/code/opus/v4.5': () => import('rhachet-brains-anthropic'), - 'anthropic/claude/code/sonnet/v4': () => import('rhachet-brains-anthropic'), + 'anthropic/claude/opus/v4.5': () => import('rhachet-brains-anthropic'), + 'anthropic/claude/sonnet/v4': () => import('rhachet-brains-anthropic'), 'opencode/opencode/v1': () => import('rhachet-brains-opencode'), }; ``` diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v2.i1.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v2.i1.md index 15ed93a..fca6010 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v2.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v2.i1.md @@ -463,8 +463,8 @@ khlone resolves brain alias to BrainCli adapter: // khlone/src/domain.operations/clone/resolveRoleBrain.ts const BRAIN_ADAPTERS: Record Promise> = { - 'anthropic/claude/code/opus/v4.5': () => import('rhachet-brains-anthropic'), - 'anthropic/claude/code/sonnet/v4': () => import('rhachet-brains-anthropic'), + 'anthropic/claude/opus/v4.5': () => import('rhachet-brains-anthropic'), + 'anthropic/claude/sonnet/v4': () => import('rhachet-brains-anthropic'), 'opencode/opencode/v1': () => import('rhachet-brains-opencode'), // future: grok, codex, gemini }; @@ -585,7 +585,7 @@ khlone tracks session IDs per clone: # {worktree}/.khlone/.bind/clones/mechanic.1/state.yml slug: mechanic.1 role: ehmpathy/mechanic -brain: anthropic/claude/code/opus/v4.5 +brain: anthropic/claude/opus/v4.5 sessionId: abc123-uuid-here # <-- tracked status: idle ``` @@ -1138,8 +1138,8 @@ crew: foreman: ehmpathy/foreman brains: - claude: anthropic/claude/code/opus/v4.5 - sonnet: anthropic/claude/code/sonnet/v4 + claude: anthropic/claude/opus/v4.5 + sonnet: anthropic/claude/sonnet/v4 opencode: opencode/opencode/v1 hooks: diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v3.i1.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v3.i1.md index d1688a6..4302be8 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v3.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/2.3.criteria.zoom1.blueprint.v3.i1.md @@ -65,7 +65,7 @@ interface BrainAdapter { interface BrainAdapterMeta { /** adapter package name */ package: string; - /** brain identifier (e.g., 'anthropic/claude/code/opus/v4.5') */ + /** brain identifier (e.g., 'anthropic/claude/opus/v4.5') */ brain: string; /** implementation mode */ mode: 'cli' | 'sdk' | 'hybrid'; @@ -758,7 +758,7 @@ const resolveAdapter = async (input: { brain: string }): Promise = // rhachet/src/brains/registry.ts interface BrainMeta { - slug: string; // 'anthropic/claude/code/opus/v4.5' + slug: string; // 'anthropic/claude/opus/v4.5' package: string; // 'rhachet-brains-anthropic' name: string; // 'Claude Code Opus' mode: 'cli' | 'sdk'; @@ -767,14 +767,14 @@ interface BrainMeta { const BRAIN_REGISTRY: BrainMeta[] = [ { - slug: 'anthropic/claude/code/opus/v4.5', + slug: 'anthropic/claude/opus/v4.5', package: 'rhachet-brains-anthropic', name: 'Claude Code Opus', mode: 'cli', auth: 'cli-login', }, { - slug: 'anthropic/claude/code/sonnet/v4', + slug: 'anthropic/claude/sonnet/v4', package: 'rhachet-brains-anthropic', name: 'Claude Code Sonnet', mode: 'cli', diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.1.research.references._.v1.i1.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.1.research.references._.v1.i1.md index e699e85..d94ee33 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.1.research.references._.v1.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.1.research.references._.v1.i1.md @@ -117,7 +117,7 @@ roles: # role aliases available for --who role++ **quote:** ```yaml brains: # brain aliases for --brain - claude: anthropic/claude/code/opus/v4.5 + claude: anthropic/claude/opus/v4.5 grok: xai/grok/code-fast-1 codex: openai/codex/v5.3 gemini: google/gemini/code/v2 diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.2.distill.domain._.v1.i1.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.2.distill.domain._.v1.i1.md index 3d5049b..05dfc28 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.2.distill.domain._.v1.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.2.distill.domain._.v1.i1.md @@ -171,7 +171,7 @@ interface Clone { role: string; // role alias from crew.roles roleRef: string; // resolved "ehmpathy/mechanic" brain: string; // brain alias from crew.brains - brainRef: string; // resolved "anthropic/claude/code/opus/v4.5" + brainRef: string; // resolved "anthropic/claude/opus/v4.5" status: CloneStatus; task: { focus: { diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.3.blueprint.dispatch.v1.i1.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.3.blueprint.dispatch.v1.i1.md index a35a859..8b52ab7 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.3.blueprint.dispatch.v1.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.3.blueprint.dispatch.v1.i1.md @@ -52,7 +52,7 @@ const mechanicRole = genRole({ // brains provide inference const claudeBrain = genBrainRepl({ - slug: 'anthropic/claude/code/opus/v4.5', + slug: 'anthropic/claude/opus/v4.5', }); // actors combine role + brain @@ -109,7 +109,7 @@ interface RhachetContext { /** * create brain instance by ref - * @param brainRef - e.g., "anthropic/claude/code/opus/v4.5" + * @param brainRef - e.g., "anthropic/claude/opus/v4.5" * @returns brain ready to attach to actor */ createBrain(brainRef: string): Promise; diff --git a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.3.blueprint.v1.i1.md b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.3.blueprint.v1.i1.md index a35a50e..75da3a2 100644 --- a/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.3.blueprint.v1.i1.md +++ b/.behavior/v2026_02_15.khlone-v0/priors/v2026_02_12.khlone-worksite/3.3.blueprint.v1.i1.md @@ -697,7 +697,7 @@ config: mechanic: ehmpathy/mechanic researcher: ehmpathy/researcher brains: - claude: anthropic/claude/code/opus/v4.5 + claude: anthropic/claude/opus/v4.5 grok: xai/grok/code-fast-1 zones: - slug: "@main" @@ -732,7 +732,7 @@ zone: role: mechanic roleRef: ehmpathy/mechanic brain: claude -brainRef: anthropic/claude/code/opus/v4.5 +brainRef: anthropic/claude/opus/v4.5 status: active task: focus: diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/.bind/vlad.v0-0-bhrain-cli.flag b/.behavior/v2026_02_20.v0p0-bhrain-cli/.bind/vlad.v0-0-bhrain-cli.flag new file mode 100644 index 0000000..9240478 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/.bind/vlad.v0-0-bhrain-cli.flag @@ -0,0 +1,2 @@ +branch: vlad/v0-0-bhrain-cli +bound_by: init.behavior skill diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/.ref.[feedback].v1.[given].by_human.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/.ref.[feedback].v1.[given].by_human.md new file mode 100644 index 0000000..04791da --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/.ref.[feedback].v1.[given].by_human.md @@ -0,0 +1,27 @@ +emit your response to the feedback into +- .behavior/v2026_02_20.v0p0-bhrain-cli/$BEHAVIOR_REF_NAME.[feedback].v1.[taken].by_robot.md + +1. emit your response checklist +2. exec your response plan +3. emit your response checkoffs into the checklist + +--- + +first, bootup your mechanics briefs again + +npx rhachet roles boot --repo ehmpathy --role mechanic + +--- +--- +--- + + +# blocker.1 + +--- + +# nitpick.2 + +--- + +# blocker.3 diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/.ref.[feedback].v2.[audit-gaps].by_mechanic.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/.ref.[feedback].v2.[audit-gaps].by_mechanic.md new file mode 100644 index 0000000..6547436 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/.ref.[feedback].v2.[audit-gaps].by_mechanic.md @@ -0,0 +1,123 @@ +# block 0: BrainCli — audit gap report + +> audit of implementation vs blueprint (`3.3.blueprint.v1.i1.md`) and blackbox criteria (`2.1.criteria.blackbox.md`) + +--- + +## gap.1 = `--model` flag absent from dispatch args + +**severity**: blocker (wrong model used at runtime) + +**what**: `getOneDispatchArgs` does not pass `--model` to the claude CLI. the `BrainAtomConfig` has a `.model` field (e.g., `'claude-haiku-4-5-20251001'`, `'claude-opus-4-5-20251101'`) but it is never forwarded to the spawned process. all slugs — haiku, sonnet, opus — spawn with whatever default model the `claude` binary selects. + +**where**: `src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.ts` + +**evidence**: the args array contains `-p`, `--output-format`, `--input-format`, `--verbose`, `--allowedTools`, and optionally `--resume` — but no `--model`. + +**fix**: add `'--model', input.config.model` to the args array. requires `AnthropicBrainCliConfig` to expose the `model` field from `BrainAtomConfig`, or pass the full atom config. + +--- + +## gap.2 = `--model` flag absent from interact args + +**severity**: blocker (same root cause as gap.1) + +**what**: `getOneInteractArgs` also does not pass `--model`. interact mode should boot the correct model too. + +**where**: `src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.ts` + +**fix**: same approach as gap.1 — add `--model` from config. + +--- + +## gap.3 = `act()` has zero integration test coverage + +**severity**: blocker (usecase 4 from blackbox criteria completely untested) + +**what**: the integration test only exercises `ask()`. `act()` is never called against a live process. the criteria require: +- act returns BrainOutput with non-zero tokens +- act has access to mutation tools (Edit, Write, Bash) +- act on not-booted handle throws +- act on interact-mode handle throws + +**where**: `src/_topublish/rhachet-brains-anthropic/__test__/genBrainCli.integration.test.ts` + +**fix**: add `[t5]` or similar with `act({ prompt })` call, assert BrainOutput shape and metrics. + +--- + +## gap.4 = interact mode boot has zero integration test coverage + +**severity**: blocker (usecases 5-interact, 9, 10 from blackbox criteria untested) + +**what**: no test boots a handle in interact mode. the criteria require: +- usecase 9: dispatch → interact preserves series; interact → dispatch preserves series +- usecase 5: terminal.onData fires with raw PTY bytes in interact mode +- usecase 10: terminal.write in interact mode relays to stdin + +**where**: `src/_topublish/rhachet-brains-anthropic/__test__/genBrainCli.integration.test.ts` + +**fix**: add test cases that boot interact, verify mode switch, and test terminal i/o. + +--- + +## gap.5 = ask/act on interact-mode handle guard not tested + +**severity**: caution (usecase 3/4 guard clause untested) + +**what**: the blackbox criteria state: "given a BrainCli handle booted in interact mode, when ask/act is called, then it throws." this guard exists in prod code but is never exercised by a test. + +**where**: `src/_topublish/rhachet-brains-anthropic/__test__/genBrainCli.integration.test.ts` + +**fix**: add test: boot interact → call ask → expect throw. same for act. + +--- + +## gap.6 = sequential metrics independence not tested + +**severity**: caution (usecase 11 from blackbox criteria untested) + +**what**: the criteria state: "given two sequential ask calls on the same handle, each BrainOutput has independent metrics (per-call, not cumulative), both > 0." no test exercises two consecutive dispatch calls on the same handle. + +**where**: `src/_topublish/rhachet-brains-anthropic/__test__/genBrainCli.integration.test.ts` + +**fix**: add test: boot → ask → ask → assert each BrainOutput has independent non-zero token counts. + +--- + +## gap.7 = `AnthropicBrainCliConfig` lacks `model` field + +**severity**: blocker (prerequisite for gap.1 and gap.2 fixes) + +**what**: `AnthropicBrainCliConfig` has `{ slug, binary, spec, tools }` but no `model` field. the `BrainAtomConfig` from `rhachet-brains-anthropic` has `{ model, description, spec }`. the `model` field is lost in the `getOneConfig` helper which only extracts `.spec`. + +**where**: `src/_topublish/rhachet-brains-anthropic/BrainCli.config.ts` + +**fix**: add `model: string` to `AnthropicBrainCliConfig` and populate it from `CONFIG_BY_REPL_SLUG[replSlug].model` in `getOneConfig`. + +--- + +## gap.8 = unit tests for dispatch/interact args don't assert `--model` + +**severity**: caution (follows from gap.1/gap.2 — tests must cover model arg once added) + +**what**: `getOneDispatchArgs.test.ts` and `getOneInteractArgs.test.ts` don't check for `--model` in output args. once the flag is added to prod code, tests must verify it. + +**where**: `src/_topublish/rhachet-brains-anthropic/__test__/getOneDispatchArgs.test.ts`, `getOneInteractArgs.test.ts` + +**fix**: add assertions for `--model` presence and correct value per config. + +--- + +## summary + +| # | gap | severity | scope | +|---|-----|----------|-------| +| 1 | `--model` absent from dispatch args | blocker | prod | +| 2 | `--model` absent from interact args | blocker | prod | +| 3 | `act()` not tested | blocker | test | +| 4 | interact mode not tested | blocker | test | +| 5 | ask/act on interact guard not tested | caution | test | +| 6 | sequential metrics not tested | caution | test | +| 7 | config lacks `model` field | blocker | prod | +| 8 | unit tests don't assert `--model` | caution | test | diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md new file mode 100644 index 0000000..2ead3f2 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md @@ -0,0 +1,15 @@ +wish = + +to execute the decomposed scope of the block 0 of v0 + +as defined here .behavior/v2026_02_15.khlone-v0/3.5.decomposition.handoff.block_0.v1.i1.md + + +that whole khlone-v0 is avialable for reference + +but htis handoff do .behavior/v2026_02_15.khlone-v0/3.5.decomposition.handoff.block_0.v1.i1.md + +should give you all the info you need + +and is the authority on the scope for this particular wish + diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md new file mode 100644 index 0000000..e7aad1c --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md @@ -0,0 +1,195 @@ +# block 0: BrainCli contract + claude supplier — vision + +> a clean handle that lets you spawn, dispatch, stream, and recover a headless claude code process — tested live, ready for khlone's daemon to consume + +--- + +## the outcome world + +### before + +```ts +// you want to drive claude code headless? good luck. +import { spawn } from 'child_process'; + +const proc = spawn('claude', ['-p', '--output-format', 'stream-json', ...]); +proc.stdout.on('data', (chunk) => { + // raw bytes — partial JSON lines, split across chunks + // you parse it yourself. you handle backpressure. you track session_id. + // you figure out --allowedTools for ask vs act. + // you build your own crash recovery. + // you build your own metrics extraction. + // you do this again for codex. and opencode. and gemini. +}); +``` + +every consumer reimplements the same boilerplate: spawn args, stream parse, session continuation, crash recovery, mode enforcement. the vendor CLI is a subprocess — not a contract. + +### after + +```ts +const brainCli = await genBrainCli( + { slug: 'claude@anthropic/claude/opus/v4.5' }, + { cwd: '/home/vlad/git/ehmpathy/myrepo' }, +); + +// boot headless +await brainCli.executor.boot({ mode: 'dispatch' }); + +// ask — read-only, no side effects +const answer = await brainCli.ask({ prompt: 'what files changed?' }); +// → BrainOutput { output, metrics: { tokens, cost, time }, episode, series } + +// act — full tool use +const result = await brainCli.act({ prompt: 'implement jwt validation' }); + +// stream raw output +brainCli.terminal.onData((chunk) => console.log(chunk)); + +// crash? the handle survives. reboot, series preserved. +brainCli.terminal.onExit(() => { + brainCli.executor.boot({ mode: 'dispatch' }); // same series, new episode +}); + +// switch to interactive for talk mode +await brainCli.executor.boot({ mode: 'interact' }); +// → same handle, new process, raw PTY byte relay +``` + +one handle. two modes. structured metrics. crash recovery. session continuation. the vendor CLI details are invisible. + +### the "aha" moment + +you call `brainCli.ask({ prompt })` and get back a typed `BrainOutput` with token counts, cost, and a series ref — from a live claude code process. no stream parse code. no arg assembly. no session ID bookkeep. the contract did it all. + +then the process crashes. you call `brainCli.executor.boot({ mode: 'dispatch' })` and the handle comes back alive with the same series. the crash was a non-event. + +the handle is the abstraction that makes clones possible. + +--- + +## what this block delivers + +this is a library block — no CLI, no daemon, no khlone domain objects. pure contract + first supplier. + +### the contract (`_topublish/rhachet/`) + +the `BrainCli` interface type: the shape that every brain supplier must implement. + +- **`terminal`** — raw i/o surface: `onData`, `onExit`, `write`, `resize` +- **`executor`** — process lifecycle: `boot({ mode })`, `kill()`, `pid`, `series` +- **`ask({ prompt })`** — dispatch read-only task, returns `BrainOutput` +- **`act({ prompt })`** — dispatch full-tool task, returns `BrainOutput` + +plus `genBrainCli` — the factory that routes a brain slug to the correct supplier. + +### the claude supplier (`_topublish/rhachet-brains-anthropic/`) + +the first implementation of the contract, for claude code CLI: + +- **dispatch mode**: spawns `claude -p --input-format stream-json --output-format stream-json --allowedTools ...` +- **interact mode**: spawns `claude --resume ` in raw PTY mode +- **ask vs act**: enforced via `--allowedTools` — ask restricts to read-only tools, act permits all +- **nd-JSON stream parse**: turns claude's stream events into typed `BrainOutput` with metrics +- **series preservation**: captures `session_id` from output, passes via `--resume` on reboot + +### the tests (integration, against live claude CLI) + +every capability proven against a real claude code process: + +| what | how | +|---|---| +| boot dispatch | spawn `-p` with structured i/o, capture session_id | +| boot interact | kill dispatch process, spawn `--resume` in raw terminal mode | +| ask | send nd-JSON input, collect `BrainOutput` with metrics (tokens > 0) | +| act | same as ask, verify different `--allowedTools` set | +| terminal.onData | raw byte stream fires with output | +| terminal.onExit | exit signal fires on process death | +| kill | clean termination | +| crash → boot | series preserved, new episode, same series ref | + +--- + +## mental model + +### how you'd describe it to a friend + +> "it's a typed handle around a headless CLI process. you spawn it, send it tasks via `.ask()` and `.act()`, get back structured results with token counts, and if the process dies you reboot the handle and it picks up where it left off. the same interface works for claude, codex, opencode — swap the slug, same contract." + +### the analogy + +a database connection pool gives you a `Connection` handle — you don't care if the TCP socket reconnects behind it. `BrainCli` is the same pattern for brain CLI processes. the handle is stable; the process behind it may churn. + +### terms + +| our term | what it means | +|---|---| +| `BrainCli` | the handle type — a live brain CLI process you can drive | +| `genBrainCli` | the factory — `(input: { slug }, context: { cwd })` → live handle | +| `brain slug` | format: `@/` — the CLI references the atom (model) directly, not the repl. the CLI replaces the repl (supplies its own tool-use loop) | +| `dispatch mode` | headless nd-JSON structured i/o (for ask/act) | +| `interact mode` | raw PTY byte relay (for talk mode) | +| `BrainOutput` | structured result from ask/act — output + metrics + series ref | +| `series` | cross-boot continuation ref (claude's `session_id`) | +| `episode` | single context window within a series | +| `supplier` | vendor-specific implementation of the BrainCli contract | + +--- + +## evaluation + +### how well does it solve the goals? + +this block delivers the critical external dependency for khlone v0. without it, no clone can spawn, no task can dispatch, no output can stream, no crash can recover. with it, every khlone feature that touches a brain process has a clean contract to code against. + +### pros + +- **clean separation** — khlone never touches vendor CLI args, stream formats, or session mechanics +- **testable in isolation** — the handle works without a daemon, without khlone domain objects, without persistence +- **crash recovery built in** — the handle manages its own series ref; reboot is a single method call +- **mode enforcement at the contract level** — ask vs act is a supplier concern, not a khlone concern +- **structured metrics from day one** — tokens, cost, and time are part of every `BrainOutput` + +### cons + +- **live CLI dependency for tests** — integration tests need a real claude code binary with valid credentials on the machine +- **process churn on mode switch** — dispatch ↔ interact kills and respawns (no live mode switch on any CLI today). acceptable — the handle hides it, and mode switches are rare (only for talk mode) +- **claude-only for v0** — other suppliers (codex, opencode, gemini) follow the same pattern but aren't built yet. the contract is designed for them, but unproven until they ship + +### edge cases and pit of success + +| edge case | how the contract handles it | +|---|---| +| ask in interact mode | throws — ask/act only valid in dispatch mode. caller must boot dispatch first | +| act with no boot | throws — process not alive. caller must boot before dispatch | +| crash mid-task | `terminal.onExit` fires → caller reboots → series preserved → re-dispatch | +| double boot | second boot kills current process, respawns in new mode — idempotent-safe | +| supplier can't provide cost | `metrics.cost.cash` is null — not an error. degraded but functional | +| supplier doesn't support series | `series` is null — crash recovery starts fresh context. degraded but functional | +| invalid brain slug | `genBrainCli` throws `BadRequestError` — fail fast with clear message | + +--- + +## file inventory + +``` +src/_topublish/ +├── rhachet/ # contract (3 prod files) +│ ├── BrainCli.ts # interface type +│ ├── genBrainCli.ts # factory: slug → handle +│ └── index.ts # re-exports +└── rhachet-brains-anthropic/ # claude supplier (6 prod, 4 test) + ├── BrainCli.config.ts # slug → config map + ├── genBrainCli.ts # factory: slug → claude handle + ├── getOneBrainOutputFromStreamJson.ts # nd-JSON → BrainOutput + ├── getOneDispatchArgs.ts # CLI args for dispatch mode + ├── getOneInteractArgs.ts # CLI args for interact mode + ├── index.ts # exports + └── __test__/ + ├── genBrainCli.integration.test.ts + ├── getOneBrainOutputFromStreamJson.test.ts + ├── getOneDispatchArgs.test.ts + └── getOneInteractArgs.test.ts +``` + +9 prod files. 4 test files. 13 total. diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.src new file mode 100644 index 0000000..a5ad1b9 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.src @@ -0,0 +1,38 @@ +illustrate the vision implied in the wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md + +--- + +paint a picture of what the world looks like when this wish is fulfilled + +testdrive the contract we propose via realworld examples + +specifically, + +## the outcome world + +- what does a day-in-the-life look like with this in place? +- what's the before/after contrast? +- what's the "aha" moment where the value clicks? + +## user experience + +- what usecases do folks fulfill? what goals? +- what contract inputs & outputs do they leverage? +- what would it look like to leverage them? +- what timelines do they go through? + +## mental model + +- how would users describe this to a friend? +- what analogies or metaphors fit? +- what terms would they use vs what terms would we use? + +## evaluation + +- how well does it solve the goals? +- what are the pros? the cons? +- what edgecases exist and how do our contracts keep users in a pit of success? + +uncover anything awkward diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md new file mode 100644 index 0000000..5fe8d51 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md @@ -0,0 +1,247 @@ +# block 0: BrainCli contract + claude supplier — blackbox criteria + +> what experience must be delivered, from the caller's perspective + +--- + +## usecase.1 = spawn a brain handle from a slug + +``` +given a valid claude brain slug and a cwd + when genBrainCli is called with { slug } and { cwd } + then it returns a BrainCli handle + sothat the caller has a stable ref to drive a brain CLI process + then the handle has not yet spawned a process (pid is null, mode is null) + sothat boot is explicit — no hidden side effects on construction + +given a valid slug with explicit auth context (context.brain.auth.anthropic) + when genBrainCli is called with { slug } and { cwd, brain: { auth: { anthropic: { via: { apiKey } } } } } + then it returns a BrainCli handle that uses the provided api key for spawn env + sothat cicd and tests can pass explicit credentials + +given a valid slug with no auth context (brain.auth omitted) + when genBrainCli is called with { slug } and { cwd } + then it returns a BrainCli handle that defaults to oauth + sothat local dev works out of the box without explicit auth + +given a valid slug with invalid auth shape (brain.auth.anthropic present but malformed) + when genBrainCli is called + then it throws a BadRequestError with the invalid auth shape in metadata + sothat the caller fails fast when auth is provided but wrong + +given an unrecognized brain slug + when genBrainCli is called + then it throws a BadRequestError with the invalid slug in the message + sothat the caller fails fast with a clear explanation +``` + +--- + +## usecase.2 = boot a handle into dispatch mode + +``` +given a BrainCli handle that has not been booted + when executor.boot({ mode: 'dispatch' }) is called + then the handle spawns a headless CLI process + then pid is updated to a live process id + then mode is 'dispatch' + sothat the handle is ready to accept ask/act calls + +given a BrainCli handle already in dispatch mode + when executor.boot({ mode: 'dispatch' }) is called again + then the prior process is killed + then a new process is spawned in dispatch mode + then series is preserved across the reboot + sothat double-boot is safe and idempotent +``` + +--- + +## usecase.3 = dispatch a read-only task via ask + +``` +given a BrainCli handle booted in dispatch mode + when ask({ prompt: 'what files are in this repo?' }) is called + then it returns a BrainOutput with a text output + then BrainOutput.metrics.size.tokens.input is > 0 + then BrainOutput.metrics.size.tokens.output is > 0 + then BrainOutput.episode is a non-empty text ref + then BrainOutput.series is a non-empty text ref + sothat the caller receives structured results with metrics from a live brain + +given a BrainCli handle booted in dispatch mode + when ask({ prompt }) is called + then the brain CLI process is restricted to read-only tools + then no files are mutated by the brain + sothat ask mode enforces zero side effects + +given a BrainCli handle that has not been booted + when ask({ prompt }) is called + then it throws an error + sothat the caller cannot dispatch without a live process + +given a BrainCli handle booted in interact mode + when ask({ prompt }) is called + then it throws an error + sothat ask is only valid in dispatch mode +``` + +--- + +## usecase.4 = dispatch a full-tool task via act + +``` +given a BrainCli handle booted in dispatch mode + when act({ prompt: 'create a file called hello.txt with "hello"' }) is called + then it returns a BrainOutput with a text output + then BrainOutput.metrics.size.tokens.input is > 0 + then BrainOutput.metrics.size.tokens.output is > 0 + then BrainOutput.episode is a non-empty text ref + then BrainOutput.series is a non-empty text ref + sothat the caller receives structured results from a live brain with full tool access + +given a BrainCli handle booted in dispatch mode + when act({ prompt }) is called + then the brain CLI process has access to mutation tools (file edit, bash, etc) + sothat act mode permits full tool use + +given a BrainCli handle that has not been booted + when act({ prompt }) is called + then it throws an error + sothat the caller cannot dispatch without a live process + +given a BrainCli handle booted in interact mode + when act({ prompt }) is called + then it throws an error + sothat act is only valid in dispatch mode +``` + +--- + +## usecase.5 = stream raw output from the process + +``` +given a BrainCli handle booted in dispatch mode + when a task is dispatched via ask or act + then terminal.onData fires with output chunks as the brain produces them + then the chunks contain the brain's response data + sothat the caller can stream output in real time (for watch mode, transcript capture) + +given a BrainCli handle booted in interact mode + when the brain CLI emits terminal output + then terminal.onData fires with raw PTY bytes + sothat the caller can relay the byte stream to a user terminal +``` + +--- + +## usecase.6 = detect process exit + +``` +given a BrainCli handle with a live process + when the process exits cleanly (exit code 0) + then terminal.onExit fires with the exit code + sothat the caller knows the process terminated + +given a BrainCli handle with a live process + when the process crashes (non-zero exit code or signal) + then terminal.onExit fires with the exit code and/or signal + sothat the caller can trigger crash recovery + +given a BrainCli handle with a live process + when the process is killed externally + then terminal.onExit fires exactly once + sothat the caller is notified without duplicate signals +``` + +--- + +## usecase.7 = kill the process + +``` +given a BrainCli handle with a live process + when kill() is called + then the process is terminated + then terminal.onExit fires + sothat the caller can cleanly shut down a clone + +given a BrainCli handle with no live process (not booted, or already exited) + when kill() is called + then it is a no-op (no error thrown) + sothat kill is safe to call regardless of state +``` + +--- + +## usecase.8 = crash recovery via reboot with series preservation + +``` +given a BrainCli handle that has completed at least one ask or act call (series is non-null) + when the process crashes (terminal.onExit fires unexpectedly) + and the caller calls executor.boot({ mode: 'dispatch' }) + then a new process is spawned that resumes the prior session + then handle.series is the same ref as before the crash + then the new process has access to prior conversation context + sothat crash recovery preserves session continuity + +given a BrainCli handle with series from a prior boot + when executor.boot({ mode: 'dispatch' }) is called after a crash + then the caller can immediately dispatch a new task via ask or act + sothat crash recovery is transparent to the caller's workflow +``` + +--- + +## usecase.9 = switch between dispatch and interact mode + +``` +given a BrainCli handle booted in dispatch mode with a non-null series + when executor.boot({ mode: 'interact' }) is called + then the dispatch process is killed + then a new process is spawned in interact mode (raw PTY) + then mode is updated to 'interact' + then series is preserved + sothat the caller can switch a clone to interactive terminal mode + +given a BrainCli handle booted in interact mode + when executor.boot({ mode: 'dispatch' }) is called + then the interact process is killed + then a new process is spawned in dispatch mode (structured i/o) + then mode is updated to 'dispatch' + then series is preserved + sothat the caller can switch back to headless dispatch after a talk session +``` + +--- + +## usecase.10 = write raw input to the process + +``` +given a BrainCli handle booted in interact mode + when terminal.write(data) is called with text + then the text is written to the process stdin + sothat the caller can relay user keystrokes to the brain (talk mode) + +given a BrainCli handle booted in dispatch mode + when terminal.write(data) is called + then the data is written to the structured stdin + sothat the caller can send raw protocol data if needed +``` + +--- + +## usecase.11 = structured metrics per dispatch call + +``` +given a BrainCli handle booted in dispatch mode + when ask or act returns a BrainOutput + then metrics.size.tokens has input and output counts (both > 0) + then metrics.cost.time is a duration value + then metrics.cost.cash is either a cost object or null (if supplier cannot provide cost) + sothat the caller has per-call token and cost data without vendor output parse + +given two sequential ask calls on the same handle + when both return BrainOutput + then each BrainOutput has independent metrics (per-call, not cumulative) + sothat the caller can attribute cost to individual tasks +``` diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.src new file mode 100644 index 0000000..3ce089e --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.src @@ -0,0 +1,61 @@ +declare the blackbox criteria required to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) + +via bdd declarations, per your briefs + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md + +--- + +blackbox criteria = experience boundaries (no implementation details) + +## episode experience + +a sequence of exchanges — the narrative flow + +- what workflows do users go through? +- what do they see, do, and receive at each step? +- what are the critical paths through the episode? +- what are the edge cases in the narrative? + +## exchange experience + +atomic — a single input→output contract + +- what inputs does the system accept? +- what outputs does the system return? +- what errors does the system surface? +- what are the boundary conditions? + +--- + +DO NOT include: +- mechanism details (what contracts/components exist) +- implementation details (how things are built) + +note: blackbox is NOT "why to build" — that's the wish + blackbox is "what experience must be delivered" to fulfill the wish + +--- + +## template + +``` +# usecase.1 = ... +given() + when() + then() + sothat() + then() + then() + sothat() + when() + then() + +given() + ... + +# usecase.2 = ... +... +``` diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/2.2.criteria.blackbox.matrix.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.2.criteria.blackbox.matrix.md new file mode 100644 index 0000000..f1b4185 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.2.criteria.blackbox.matrix.md @@ -0,0 +1,132 @@ +# block 0: BrainCli — blackbox coverage matrix + +> distilled from `2.1.criteria.blackbox.md` — all dimension combinations with expected outcomes + +--- + +## matrix 1: genBrainCli (usecase.1) + +| ind: slug validity | dep: returns | dep: error | +|---|---|---| +| valid claude slug | BrainCli handle (pid=null, mode=null) | — | +| unrecognized slug | — | BadRequestError with slug in message | + +**dimensions:** 1 independent, 2 dependent. complete — no gaps. + +--- + +## matrix 2: executor.boot (usecases 2, 8, 9) + +| ind: handle state before boot | ind: target mode | dep: process action | dep: pid | dep: mode after | dep: series | +|---|---|---|---|---|---| +| not booted (fresh handle) | dispatch | spawn new | live pid | dispatch | null (no prior series) | +| not booted (fresh handle) | interact | spawn new | live pid | interact | null (no prior series) | +| booted dispatch (no series) | dispatch | kill + respawn | new live pid | dispatch | null | +| booted dispatch (with series) | dispatch | kill + respawn | new live pid | dispatch | preserved | +| booted dispatch (with series) | interact | kill + respawn | new live pid | interact | preserved | +| booted interact | dispatch | kill + respawn | new live pid | dispatch | preserved | +| booted interact | interact | kill + respawn | new live pid | interact | preserved | +| crashed (process dead, series extant) | dispatch | spawn new (resume) | new live pid | dispatch | preserved | +| crashed (process dead, series extant) | interact | spawn new (resume) | new live pid | interact | preserved | + +**dimensions:** 2 independent (handle state × target mode), 4 dependent. 9 combinations. + +**gap flag:** "not booted + interact" — the blackbox criteria don't explicitly address fresh-handle boot to interact mode. however, it follows the same pattern as fresh-handle boot to dispatch. no behavioral gap — the contract should support it. + +--- + +## matrix 3: dispatch methods — ask and act (usecases 3, 4) + +| ind: handle state | ind: method | dep: returns | dep: tool access | dep: error | +|---|---|---|---|---| +| booted dispatch | ask | BrainOutput (output + metrics + episode + series) | read-only tools | — | +| booted dispatch | act | BrainOutput (output + metrics + episode + series) | all tools (read + write) | — | +| not booted | ask | — | — | throws (no live process) | +| not booted | act | — | — | throws (no live process) | +| booted interact | ask | — | — | throws (wrong mode) | +| booted interact | act | — | — | throws (wrong mode) | + +**dimensions:** 2 independent (handle state × method), 3 dependent. 6 combinations. complete — no gaps. + +**note:** ask and act share identical guard behavior (not-booted → error, interact → error). the only difference is tool access scope. this symmetry is clean — no decomposition needed. + +--- + +## matrix 4: terminal.onData (usecase.5) + +| ind: mode | ind: event source | dep: onData fires | dep: data format | +|---|---|---|---| +| dispatch | ask/act response stream | yes | structured output chunks | +| interact | brain CLI terminal output | yes | raw PTY bytes | +| not booted | — | no (no process) | — | + +**dimensions:** 2 independent (mode × event source), 2 dependent. 3 combinations. complete. + +--- + +## matrix 5: terminal.onExit (usecase.6) + +| ind: exit cause | dep: onExit fires | dep: payload | dep: fire count | +|---|---|---|---| +| clean exit (code 0) | yes | exit code | exactly once | +| crash (non-zero code) | yes | exit code | exactly once | +| signal (SIGTERM, SIGKILL, etc) | yes | signal name | exactly once | +| external kill | yes | exit code and/or signal | exactly once | + +**dimensions:** 1 independent (exit cause), 3 dependent. 4 combinations. complete. + +--- + +## matrix 6: kill (usecase.7) + +| ind: handle state | dep: process action | dep: onExit fires | dep: error | +|---|---|---|---| +| live process | terminate | yes | — | +| not booted | no-op | no | — (no error) | +| already exited | no-op | no | — (no error) | + +**dimensions:** 1 independent, 3 dependent. 3 combinations. complete. + +--- + +## matrix 7: terminal.write (usecase.10) + +| ind: mode | dep: write target | dep: data format | +|---|---|---| +| dispatch | structured stdin | protocol data | +| interact | process stdin | raw text (user keystrokes) | + +**dimensions:** 1 independent, 2 dependent. 2 combinations. complete. + +**gap flag:** what happens if terminal.write is called when not booted? the blackbox criteria don't specify. expected: throw or no-op. recommend: throw (consistent with ask/act guard pattern). + +--- + +## matrix 8: BrainOutput metrics (usecase.11) + +| ind: call sequence | dep: tokens.input | dep: tokens.output | dep: cost.time | dep: cost.cash | dep: scope | +|---|---|---|---|---|---| +| first ask/act call | > 0 | > 0 | duration value | cost object or null | per-call | +| second ask/act call (same handle) | > 0 | > 0 | duration value | cost object or null | per-call (independent of first) | + +**dimensions:** 1 independent, 5 dependent. 2 combinations. complete. + +--- + +## gap summary + +| # | gap | source | recommendation | +|---|---|---|---| +| 1 | fresh handle boot to interact (no prior series) | matrix 2 | no behavioral gap — same pattern as dispatch. confirm in criteria if desired | +| 2 | terminal.write when not booted | matrix 7 | add guard: throw error (consistent with ask/act guards) | + +--- + +## decomposition health + +all matrices have 1-2 independent dimensions. no matrix exceeds 9 combinations. the behavioral boundaries are well-scoped — no decomposition needed. + +the usecases cluster into three clean groups: +1. **lifecycle** (matrices 1, 2, 6): spawn → boot → kill +2. **dispatch** (matrices 3, 8): ask/act → BrainOutput with metrics +3. **terminal i/o** (matrices 4, 5, 7): onData, onExit, write diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/2.2.criteria.blackbox.matrix.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.2.criteria.blackbox.matrix.src new file mode 100644 index 0000000..e2b11dc --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.2.criteria.blackbox.matrix.src @@ -0,0 +1,47 @@ +distill the blackbox criteria in .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md into a coverage matrix + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/2.2.criteria.blackbox.matrix.md + +--- + +create a matrix table for each related set of usecases + +## process + +1. **extract dimensions** — identify the independent variables that vary across usecases +2. **enumerate combinations** — list all dimension value combinations +3. **map outcomes** — for each combination, record the expected outcome from blackbox criteria +4. **flag gaps** — if any combination lacks a specified outcome, call it out +5. **flag decomposition opportunities** — if too many dimensions, suggest narrower behavioral boundaries + +## structure + +| ind: var 1 | ind: var 2 | ... | dep: var 1 | dep: var 2 | ... | +|-------------------|-------------------|-----|-----------------|-----------------|-----| +| condition A | condition X | ... | outcome 1 | outcome 2 | ... | +| condition A | condition Y | ... | outcome 1 | outcome 2 | ... | +| condition B | condition X | ... | outcome 1 | outcome 2 | ... | + +explicitly label the ind(ependent) vs dep(endent) varialbes in the table header, as well + +## terminology + +- independent variables: the inputs/conditions that vary between subcases +- dependent variables: the expected outcomes for each combination (can be multiple per row) + +## why + +- visualize all combinations at a glance +- spot gaps via symmetric analysis — if a row is absent, ask why +- verify the blackbox criteria covers all meaningful permutations + +## decomposition signal + +if there are too many independent variables (matrix explodes) — this signals the usecase is too broad + +callout opportunities to decompose into smaller behavioral boundaries when: +- the matrix has 4+ independent dimensions +- combinations exceed what's reasonable to enumerate +- unrelated concerns are bundled together + +a narrower scope = a clearer matrix = a more maintainable and recomposable system diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.src new file mode 100644 index 0000000..85cf9f5 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.src @@ -0,0 +1,65 @@ +declare the blueprint criteria (mechanism bounds) that satisfies the blackbox criteria + +ref: +- blackbox criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md +- wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md + +--- + +blueprint criteria = MECHANISM BOUNDS +- constraints on what contracts & composition must exist to deliver the experience +- this is OPTIONAL — not all behaviors need prescribed mechanism bounds + +first, confirm which blackbox experience bounds will be satisfied + +then, declare ONLY: +- what subcomponents are demanded by the wish, vision, or criteria.blackbox? and with what contracts and boundaries? +- how do subcomponents compose together? +- what integration boundaries exist? +- what test coverage is required? + +DO NOT prescribe: +- internal implementation details of subcomponents +- how subcomponents achieve their contracts internally +- any subcomponents not explicitly demanded in the wish, vision, or criteria.blackbox + +note: blueprint criteria is NOT "how to build" — that's decided in blueprint.md (3.3) + blueprint criteria is "what mechanisms must exist" to deliver the experience + +the HOW is discovered during research (3.1) and decided during blueprint (3.3) + +--- + +## template + +``` +## blackbox criteria satisfied + +- usecase.1 = ... ✓ +- usecase.2 = ... ✓ + +## subcomponent contracts + +given('componentName contract') + then('exposes: methodName(input: Type) => ReturnType') + then('throws ErrorType for invalid inputs') + +given('anotherComponent contract') + then('exposes: ...') + +## composition boundaries + +given('feature implementation') + then('composes componentA and componentB') + then('componentA provides X, componentB transforms to Y') + +## test coverage criteria + +given('feature') + then('has unit tests for ...') + then('has integration tests for ...') + then('has acceptance test for full usecase') +``` diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md new file mode 100644 index 0000000..6088411 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md @@ -0,0 +1,371 @@ +# block 0: BrainCli — access research + +> remote access required to fulfill the BrainCli contract + claude supplier + +--- + +## lesson.1 = claude code CLI dispatch mode (`-p` with structured i/o) + +the claude supplier spawns `claude -p` with nd-JSON structured i/o for headless dispatch. this is the core access pattern for ask/act. + +### spawn args + +```sh +claude -p \ + --output-format stream-json \ + --input-format stream-json \ + --verbose \ + --allowedTools "Read,Grep,Glob" +``` + +**key flags:** +- `-p` / `--print` — headless mode: process prompt, emit output, no TUI [1] +- `--output-format stream-json` — nd-JSON event stream on stdout [1][2] +- `--input-format stream-json` — nd-JSON message protocol on stdin [1][2] +- `--verbose` — emit full event detail in stream mode [2] +- `--allowedTools` — auto-approve listed tools without prompt [1][3] +- `--resume ` — continue a prior session [1][4] +- `--continue` / `-c` — continue most recent conversation [1] + +**citation [1]:** claude code CLI reference (https://code.claude.com/docs/en/cli-reference): +> "claude -p, --print: print response without interactive mode" +> "--output-format: output format for non-interactive mode (text, json, stream-json)" +> "--input-format: input format (text, stream-json)" + +**citation [2]:** claude code headless docs (https://code.claude.com/docs/en/headless): +> "run claude code programmatically with -p and --output-format stream-json" +> "use --input-format stream-json to send nd-JSON messages on stdin for multi-turn" + +--- + +## lesson.2 = nd-JSON output event stream + +`--output-format stream-json` emits newline-delimited JSON events on stdout. the claude supplier must parse these to produce `BrainOutput`. + +### event types + +| event type | what it carries | relevance to BrainCli | +|---|---|---| +| `message_start` | message metadata, initial `usage.input_tokens` | capture input token count | +| `content_block_start` | new content block (text or tool_use) | detect response start | +| `content_block_delta` | incremental text or tool input | stream to terminal.onData | +| `content_block_stop` | end of content block | — | +| `message_delta` | `stop_reason`, cumulative `usage` (output_tokens, cache tokens) | capture final metrics | +| `message_stop` | stream complete | signal dispatch complete | +| `keepalive` | heartbeat | ignore | +| `error` | error detail | surface to caller | + +### metrics extraction from events + +the `message_start` event carries initial input tokens: +```json +{ + "type": "message_start", + "message": { + "usage": { "input_tokens": 1500, "output_tokens": 0 } + } +} +``` + +the `message_delta` event carries cumulative output tokens and cache data: +```json +{ + "type": "message_delta", + "usage": { + "input_tokens": 1500, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 500, + "output_tokens": 150 + } +} +``` + +**note:** token counts in `message_delta` are cumulative per-turn, not incremental [5]. + +**citation [3]:** claude code allowed tools docs (https://www.instructa.ai/blog/claude-code/how-to-use-allowed-tools-in-claude-code): +> "--allowedTools auto-approves specific tools without permission prompts" +> "supports pattern match with prefix wildcards: Bash(git diff *)" + +--- + +## lesson.3 = nd-JSON input protocol (`--input-format stream-json`) + +stdin accepts nd-JSON messages for multi-turn dispatch on a live process. + +### message shape + +```json +{ + "type": "user", + "message": { + "role": "user", + "content": "what files changed?" + }, + "session_id": "abc-123" +} +``` + +each line on stdin is a complete JSON object. the process reads it, executes, and emits the response event stream on stdout [2]. + +**key property:** multi-turn over a single process — send a message, get the response stream, send another message. no respawn needed between ask/act calls on the same handle [2]. + +**citation [4]:** claude code session management (https://stevekinney.com/courses/ai-development/claude-code-session-management): +> "session_id from JSON output can be captured and passed back via --resume" +> "conversations stored in ~/.claude/projects//.jsonl" + +--- + +## lesson.4 = session continuation via `--resume` + +claude code stores sessions on disk. the `--resume ` flag loads prior context into a new process. + +### how it works + +1. dispatch a task via `-p --output-format json` — the response includes `session_id` +2. the session transcript persists at `~/.claude/projects//.jsonl` +3. on respawn (crash recovery or mode switch), pass `--resume ` to load prior context + +```sh +# capture session_id from first dispatch +session_id=$(claude -p "initial task" --output-format json | jq -r '.session_id') + +# resume that session in a new process +claude -p --resume "$session_id" "follow-up task" +``` + +**this is the series ref for the BrainCli contract.** the handle stores the session_id and passes it on reboot. + +**citation [5]:** anthropic stream protocol docs (https://platform.claude.com/docs/en/build-with-claude/streaming): +> "message_delta usage fields are cumulative, not incremental" +> "token counts include cache_creation_input_tokens and cache_read_input_tokens" + +--- + +## lesson.5 = ask vs act enforcement via `--allowedTools` + +task mode is enforced at the CLI arg level via `--allowedTools` and `--disallowedTools`. + +### ask mode (read-only) + +```sh +claude -p --allowedTools "Read,Grep,Glob,WebSearch,WebFetch" \ + --output-format stream-json \ + --input-format stream-json +``` + +these tools are already auto-allowed by default (no prompt needed): `Read`, `Grep`, `Glob`, `WebSearch`, `WebFetch` [3]. + +### act mode (full tool use) + +```sh +claude -p --allowedTools "Read,Grep,Glob,Edit,Write,Bash,WebSearch,WebFetch" \ + --output-format stream-json \ + --input-format stream-json +``` + +**note:** never use `--dangerously-skip-permissions`. use explicit `--allowedTools` to whitelist permitted tools [1][3]. + +### pattern match syntax + +`--allowedTools` supports prefix wildcards: +```sh +--allowedTools "Bash(git diff *)" "Bash(git log *)" "Read" +``` + +the space before `*` matters — `Bash(git diff *)` matches `git diff HEAD` but not `git diff-index` [3]. + +--- + +## lesson.6 = auth: zero work for v0 + +auth is not part of the BrainCli contract. the claude supplier spawns the CLI process; the CLI process authenticates itself via its own credential chain. + +### auth chain (in priority order) + +1. `ANTHROPIC_API_KEY` env var — overrides all else [6] +2. `~/.claude/.credentials.json` — OAuth tokens from `claude login` [6] +3. `~/.claude/config.json` — console API key via `primaryApiKey` [6] +4. `apiKeyHelper` in `~/.claude/settings.json` — external credential command [6] + +### v0 strategy + +the daemon runs as the same user on the same machine where the human authenticated. the subprocess inherits `$HOME` and reads the same credentials file. no env var relay, no credential management, no token refresh by khlone. + +**precondition check only:** before first boot, verify credentials exist or `ANTHROPIC_API_KEY` is set. fail fast with clear error if neither found. + +**citation [6]:** prior research: auth strategy (`.behavior/v2026_02_15.khlone-v0/3.3.blueprint.z3.braincli-auth.v1.i1.md`): +> "on linux, claude -p reads ~/.claude/.credentials.json from the filesystem" +> "any process that runs as the same user and inherits the same $HOME will use the same tokens" + +--- + +## lesson.7 = process spawn via node child_process + +the claude supplier spawns the CLI via `child_process.spawn()` with pipe stdio for structured i/o. + +### dispatch mode spawn + +```ts +import { spawn } from 'child_process'; + +const child = spawn('claude', [ + '-p', + '--output-format', 'stream-json', + '--input-format', 'stream-json', + '--verbose', + '--allowedTools', 'Read,Grep,Glob', // ask mode +], { + cwd: '/path/to/worktree', + env: { ...process.env }, + stdio: ['pipe', 'pipe', 'pipe'], +}); +``` + +### interact mode spawn + +```ts +const child = spawn('claude', [ + '--resume', sessionId, +], { + cwd: '/path/to/worktree', + env: { ...process.env }, + stdio: 'inherit', // raw PTY pass-through +}); +``` + +**key difference:** dispatch mode uses `stdio: ['pipe', 'pipe', 'pipe']` for structured i/o. interact mode uses `stdio: 'inherit'` (or PTY via node-pty) for raw terminal relay. + +**citation [7]:** node.js child_process docs (https://nodejs.org/api/child_process.html): +> "child_process.spawn() launches a new process with a given command" +> "by default, pipes for stdin, stdout, and stderr are established" + +--- + +## lesson.8 = PTY via node-pty for interact mode + +interact mode needs PTY for full terminal emulation (escape codes, window resize, raw input). `node-pty` provides this. + +```ts +import { spawn as ptySpawn } from 'node-pty'; + +const pty = ptySpawn('claude', ['--resume', sessionId], { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: '/path/to/worktree', + env: process.env, +}); + +// terminal.onData +pty.onData((data) => { /* raw PTY bytes */ }); + +// terminal.onExit +pty.onExit(({ exitCode, signal }) => { /* process terminated */ }); + +// terminal.write +pty.write('user input\n'); + +// terminal.resize +pty.resize(cols, rows); +``` + +**why PTY for interact mode (not plain pipes):** + +| feature | stdin/stdout pipes | PTY | +|---|---|---| +| terminal emulation | no | yes | +| ANSI escape codes | breaks | works | +| interactive prompts | breaks | works | +| window resize (SIGWINCH) | no | yes | + +**citation [8]:** node-pty npm package (https://www.npmjs.com/package/node-pty): +> "node-pty forks processes with pseudoterminal file descriptors" + +--- + +## lesson.9 = cost data is derived, not directly emitted + +claude code does not emit per-call cost in USD as a structured field. the supplier must derive cost from token counts and model-specific rates. + +### what the stream provides + +- `input_tokens` — from `message_start` and `message_delta` +- `output_tokens` — from `message_delta` +- `cache_creation_input_tokens` — from `message_delta` +- `cache_read_input_tokens` — from `message_delta` + +### what the supplier must compute + +``` +cost.input = input_tokens * rate.input +cost.output = output_tokens * rate.output +cost.cache = cache_creation * rate.cache_write + cache_read * rate.cache_read +cost.total = cost.input + cost.output + cost.cache +``` + +**alternative:** claude code exposes `claude_code.cost.usage` via OpenTelemetry for session-level cost [9]. but for per-call metrics, the supplier computes from tokens. + +**citation [9]:** claude code observability docs (https://code.claude.com/docs/en/monitoring-usage): +> "claude_code.cost.usage metric via OpenTelemetry tracks session cost in USD" +> "ccusage, goccc, and claudelytics parse session JSONL files for usage analysis" + +--- + +## lesson.10 = known edge cases in stream-json mode + +### hang after completion + +claude code CLI may hang after the final `message_stop` event in stream-json mode instead of clean exit [10]. the supplier must detect `message_stop` and treat it as dispatch complete — not wait for process exit. + +### partial JSON in tool_use deltas + +`content_block_delta` events with `type: "input_json_delta"` emit partial JSON. the supplier must accumulate `partial_json` chunks until `content_block_stop` before parse [5]. + +### duplicate session entries + +multi-turn conversations with `--input-format stream-json` may cause duplicate session history entries [10]. not a blocker for v0 — the session_id ref remains valid. + +**citation [10]:** claude code community issues: +> "claude code CLI may hang indefinitely after final result event in stream-json mode" (github issue #25629) +> "multi-turn with --input-format stream-json may cause duplicate session history entries" (github issue #5034) + +--- + +## access summary + +### remote repositories accessed by block 0 + +| repository | type | interface | purpose | +|---|---|---|---| +| claude code CLI | process | `child_process.spawn()` + pipe stdio | dispatch mode (ask/act) | +| claude code CLI | process | `node-pty` spawn + PTY | interact mode (talk) | +| `~/.claude/.credentials.json` | filesystem | node `fs` | auth precondition check | +| `~/.claude/projects/` | filesystem | (claude-internal) | session persistence (managed by CLI, not by supplier) | + +### best practices + +1. **`--output-format stream-json` + `--input-format stream-json`** for structured multi-turn dispatch on a single process [1][2] +2. **`--allowedTools`** for mode enforcement — never `--dangerously-skip-permissions` [3] +3. **`--resume `** for series continuation across boots [4] +4. **detect `message_stop`** to signal dispatch complete — do not rely on process exit (may hang) [10] +5. **accumulate `partial_json`** in tool_use deltas until `content_block_stop` [5] +6. **derive cost from tokens** — no direct USD field in stream events [9] +7. **PTY for interact mode, pipes for dispatch mode** — different spawn strategies per mode [7][8] +8. **zero auth work** — the CLI authenticates itself; supplier only checks precondition [6] + +--- + +## citations + +| # | source | type | +|---|--------|------| +| [1] | claude code CLI reference (https://code.claude.com/docs/en/cli-reference) | primary | +| [2] | claude code headless docs (https://code.claude.com/docs/en/headless) | primary | +| [3] | claude code allowed tools guide (https://www.instructa.ai/blog/claude-code/how-to-use-allowed-tools-in-claude-code) | primary | +| [4] | claude code session management (https://stevekinney.com/courses/ai-development/claude-code-session-management) | primary | +| [5] | anthropic stream protocol docs (https://platform.claude.com/docs/en/build-with-claude/streaming) | primary | +| [6] | prior research: auth strategy (`.behavior/v2026_02_15.khlone-v0/3.3.blueprint.z3.braincli-auth.v1.i1.md`) | prior | +| [7] | node.js child_process docs (https://nodejs.org/api/child_process.html) | primary | +| [8] | node-pty npm package (https://www.npmjs.com/package/node-pty) | primary | +| [9] | claude code observability docs (https://code.claude.com/docs/en/monitoring-usage) | primary | +| [10] | claude code community issues (#25629, #5034) | primary | diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.src new file mode 100644 index 0000000..ff3dcf0 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.src @@ -0,0 +1,21 @@ +research the remote access required in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md (if declared) + +specifically +- what are the remote repositories (databases, apis, filesystems, etc) that we need to access? +- what are their contracts? (and via what interfaces? sdks? apis? etc) +- what are the best practices for how to access them? (industry wide? within this repo?) + +--- + +enumerate each lesson +- cite every claim +- number each citation +- clone exact quotes from each citation + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md new file mode 100644 index 0000000..2efae14 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md @@ -0,0 +1,283 @@ +# block 0: BrainCli — claims research + +> facts, assumptions, questions, and opinions relevant to the BrainCli contract + claude supplier + +--- + +## citation index + +| # | source | url | +|---|--------|-----| +| 1 | claude code CLI reference | https://code.claude.com/docs/en/cli-reference | +| 2 | claude code headless docs | https://code.claude.com/docs/en/headless | +| 3 | claude agent SDK overview | https://platform.claude.com/docs/en/agent-sdk/overview | +| 4 | claude agent SDK npm | https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk | +| 5 | claude agent SDK stream docs | https://platform.claude.com/docs/en/agent-sdk/stream-output | +| 6 | stream-json hang bug #25629 | https://github.com/anthropics/claude-code/issues/25629 | +| 7 | result event bug #1920 | https://github.com/anthropics/claude-code/issues/1920 | +| 8 | feb 2026 release notes | https://releasebot.io/updates/anthropic/claude-code | +| 9 | node-pty github | https://github.com/microsoft/node-pty | +| 10 | node-pty npm | https://www.npmjs.com/package/node-pty | +| 11 | @lydell/node-pty npm | https://www.npmjs.com/package/@lydell/node-pty | +| 12 | claude code permissions docs | https://code.claude.com/docs/en/permissions | +| 13 | claude code allowed tools guide | https://www.instructa.ai/blog/claude-code/how-to-use-allowed-tools-in-claude-code | +| 14 | allowedTools bypass bug #12232 | https://github.com/anthropics/claude-code/issues/12232 | +| 15 | claude code tools reference | https://www.vtrivedy.com/posts/claudecode-tools-reference | +| 16 | claude code sandbox docs | https://code.claude.com/docs/en/sandbox | +| 17 | 8 claude code exploits (flatt.tech) | https://flatt.tech/research/posts/pwn-claude-code-in-8-ways/ | +| 18 | claude code security docs | https://code.claude.com/docs/en/security | +| 19 | claude code common workflows | https://code.claude.com/docs/en/common-workflows | +| 20 | claude code --resume FAQ | https://claudelog.com/faqs/what-is-resume-flag-in-claude-code/ | +| 21 | session management guide | https://stevekinney.com/courses/ai-development/claude-code-session-management | +| 22 | session history lost bug #12114 | https://github.com/anthropics/claude-code/issues/12114 | +| 23 | config corruption bug #15608 | https://github.com/anthropics/claude-code/issues/15608 | +| 24 | severe corruption bug #18998 | https://github.com/anthropics/claude-code/issues/18998 | +| 25 | large session file freeze #21022 | https://github.com/anthropics/claude-code/issues/21022 | +| 26 | stdin consumed by shell detection #12507 | https://github.com/anthropics/claude-code/issues/12507 | +| 27 | raw mode error with piped stdin #1072 | https://github.com/anthropics/claude-code/issues/1072 | +| 28 | stdin hang with /dev/stdin #16306 | https://github.com/anthropics/claude-code/issues/16306 | +| 29 | ccusage github | https://github.com/ryoppippi/ccusage | +| 30 | goccc github | https://github.com/backstabslash/goccc | +| 31 | claude agent SDK cost docs | https://platform.claude.com/docs/en/agent-sdk/cost-track | +| 32 | claude API price guide (feb 2026) | https://costgoat.com/pricing/claude-api | +| 33 | claude API cache price tiers | https://platform.claude.com/docs/en/about-claude/pricing | +| 34 | opus 4.6 price guide | https://blog.laozhang.ai/en/posts/claude-opus-4-6-price-subscription-guide | +| 35 | SIGTERM graceful termination | https://komodor.com/learn/sigterm-signal-15-exit-code-143-linux-graceful-termination/ | +| 36 | docker stop grace period | https://www.suse.com/c/observability-sigkill-vs-sigterm/ | +| 37 | zombie processes in linux | https://linuxvox.com/blog/zombie-process-linux/ | +| 38 | orphan processes in linux | https://www.tutorialspoint.com/zombie-and-orphan-processes-in-linux | +| 39 | stream-json chain wiki | https://github.com/ruvnet/claude-flow/wiki/Stream-Chain | +| 40 | node-pty prebuilds PR #809 | https://github.com/microsoft/node-pty/pull/809 | +| 41 | cdktf child_process vs node-pty #3675 | https://github.com/hashicorp/terraform-cdk/issues/3675 | +| 42 | headless mode tutorial | https://www.claudecode101.com/en/tutorial/advanced/headless-mode | + +--- + +## section 1: stream-json protocol + +**claim 1** — [FACT] `--output-format stream-json` emits newline-delimited JSON with every token, turn, and tool interaction. [1] +> "output format for non-interactive mode (text, json, stream-json)" + +**claim 2** — [FACT] stream-json enables real-time agent-to-agent output chain — one claude instance pipes nd-JSON to another. [39] +> "stream-json chain enables real-time agent-to-agent output pipe, seamless workflows where agents build upon each other's work without intermediate file storage" + +**claim 3** — [FACT] the CLI hangs indefinitely after the final result event in stream-json mode — the process never exits cleanly. [6] +> "claude code CLI hangs indefinitely after successful task completion and the final result event, with the process never exited cleanly, and required manual SIGINT or SIGKILL to terminate" + +**claim 4** — [FACT] the result event IS sent before the hang occurs — the hang is post-completion, not mid-execution. [6] +> "the result event IS sent (unlike some other issues where it's missed), but the hang occurs AFTER the result event" + +**claim 5** — [SUMP] the supplier can implement a timeout after the result event to forcefully terminate the process if needed. [6] + +**claim 6** — [FACT] a separate bug causes the final result event to be absent entirely after successful tool execution in stream-json mode. [7] +> "the CLI failed to send the required final result event in stream JSON mode after successful tool execution" + +**claim 7** — [OPIN] stream-json lacks comprehensive official documentation — the format is inferred from behavior and community research, not from a formal spec. [1][39] + +**claim 8** — [FACT] recent stability fixes (feb 2026) address stream, session preview, file handle, and several other issues. [8] +> "recent updates fix stream, session previews, file handle, and several stability issues" + +--- + +## section 2: claude agent SDK vs CLI subprocess + +**claim 9** — [FACT] the claude code SDK was renamed to "claude agent SDK" and is published as `@anthropic-ai/claude-agent-sdk`. [3] +> "SDK name change: the claude code SDK has been renamed to the claude agent SDK" + +**claim 10** — [FACT] latest release: february 18, 2026 (npm version 0.2.47). [4] + +**claim 11** — [FACT] the agent SDK exposes the same tools, agent loop, and context management that power claude code, programmable in TypeScript and Python. [3] +> "the agent SDK gives you the same tools, agent loop, and context management that power claude code" + +**claim 12** — [FACT] the agent SDK runs in-process — no subprocess management, no IPC overhead, simpler deployment. [2] +> "no subprocess management — runs in the same process as your application, with better performance, no IPC overhead for tool calls" + +**claim 13** — [OPIN] anthropic recommends the SDK over CLI for programmatic stream use. [5] +> "for programmatic stream with callbacks and message objects, the agent SDK is recommended over direct CLI integration" + +**claim 14** — [FACT] headless mode (`claude -p`) is designed for non-interactive contexts like CI, pre-commit hooks, build automation. [42] +> "headless mode is a non-interactive mode designed for automation in contexts like CI/CD, pre-commit hooks, and build automation" + +**claim 15** — [DECIDED] khlone will use the CLI subprocess, not the agent SDK. the CLI wraps the SDK with tools, prompts, behaviors, and the ability to use subscriptions (Claude Max). the SDK alone lacks these — it is a bare inference loop without the full claude code experience. CLI is the only viable path for v0. + +**claim 16** — [FACT] with `--output-format stream-json`, all messages are JSON objects with a type field, emitted as nd-JSON, with message types: `system`, `assistant`, `user`, `result`, and `stream_event`. [2] +> "all messages are JSON objects with a type field, emitted as newline-delimited JSON (NDJSON)" + +--- + +## section 3: node-pty reliability + +**claim 17** — [FACT] node-pty is forked from pty.js with better support for later Node.js versions and Windows. maintained by Microsoft. [9] +> "node-pty is forked from chjj/pty.js with the primary goals of better support for later Node.js versions and Windows" + +**claim 18** — [FACT] node-pty is not thread-safe — concurrent use across multiple worker threads can cause issues. [10] +> "node-pty is not thread safe so use across multiple worker threads in node.js could cause issues" + +**claim 19** — [FACT] all processes launched from node-pty run at the same permission level of the parent process. recommend launch inside a container for server environments. [10] +> "all processes launched from node-pty will launch at the same permission level of the parent process" + +**claim 20** — [FACT] `@lydell/node-pty` is a lighter-weight distribution that only installs prebuilt binaries for the current platform. [11] +> "@lydell/node-pty is a smaller distribution that only installs the prebuilt binaries needed for the current platform" + +**claim 21** — [FACT] a recent PR enables load of native addons directly from prebuilds directory, which makes install more durable if the install step fails (e.g., with pnpm). [40] +> "enables load native addons directly from prebuilds directory, more durable if the install step fails or isn't run" + +**claim 22** — [DECIDED] khlone will use `@lydell/node-pty` over `node-pty`. lydell's fork only installs prebuilt binaries for the current platform (smaller, more durable install). dispatch mode uses `child_process.spawn` with pipe stdio — no PTY needed. `@lydell/node-pty` is reserved for interact mode only. + +**claim 23** — [FACT] terraform-cdk moved from node-pty to `node:child_process` to avoid native addon compilation issues and support Bun + Deno. [41] +> "invoke terraform CLI with node:child_process instead of node-pty (Bun + Deno support)" + +--- + +## section 4: --allowedTools mode enforcement + +**claim 24** — [FACT] allow rules let claude code use the specified tool without manual approval. deny rules prevent use entirely. [12] +> "allow rules let claude code use the specified tool without manual approval, deny rules prevent claude code from use of the specified tool" + +**claim 25** — [FACT] out of the box, claude code has read-only access — it can look at files but cannot change or run commands without approval. [12] +> "out of the box, claude code is locked down with read-only access" + +**claim 26** — [FACT] `--allowedTools` is ignored when combined with `--permission-mode bypassPermissions`. [14] +> "with --permission-mode bypassPermissions, --allowedTools appears to be ignored — set --allowedTools Read did not restrict the toolset" + +**claim 27** — [SUMP] `--disallowedTools` works better than `--allowedTools` for restriction — it correctly blocks tools even in bypass mode. [14] + +**claim 28** — [FACT] available tools include: Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookRead, NotebookEdit, WebFetch, TodoRead, TodoWrite, WebSearch, exit_plan_mode. [15] +> "Bash, Glob, Grep, LS, exit_plan_mode, Read, Edit, MultiEdit, Write, NotebookRead, NotebookEdit, WebFetch, TodoRead, TodoWrite, and WebSearch" + +**claim 29** — [FACT] read-only tools: Read, Glob, Grep, LS, WebFetch, WebSearch, TodoRead, NotebookRead. [13] +> "read-only operations: Read, Glob, Grep, LS, WebFetch and WebSearch" + +**claim 30** — [FACT] write tools: Write, Edit, MultiEdit, NotebookEdit. [13] +> "write operations: Write, Edit, and MultiEdit" + +**claim 31** — [FACT] claude code includes an intentional sandbox escape hatch — when a command fails due to sandbox restrictions, claude can retry with `dangerouslyDisableSandbox`. [16] +> "claude code includes an intentional escape hatch mechanism that allows commands to run outside the sandbox" + +**claim 32** — [FACT] the escape hatch can be disabled via `allowUnsandboxedCommands: false` in sandbox settings. [16] +> "this escape hatch can be disabled by set allowUnsandboxedCommands: false" + +**claim 33** — [FACT] security research found 8 ways to bypass claude code restrictions: ExitPlanMode escape, git abbreviation bypass, sed command execution, bash variable expansion, and more. [17] +> "use git with abbreviated arguments like --upload-pa can bypass claude code's blocklist mechanism" +> "the sed command with the e modifier can execute shell commands" +> "bash variable expansion syntax can be abused to execute arbitrary commands" + +**claim 34** — [FACT] write operations are confined to the project scope — claude code cannot modify files in parent directories without explicit permission. [18] +> "claude code can only write to the folder where it was started and its subfolders" + +**claim 35** — [KHUE] is `--allowedTools` sufficient for ask-mode read-only enforcement? given the known bypasses (claim 33), should khlone add additional guards (e.g., git diff post-check to verify no mutations)? + +--- + +## section 5: session resume reliability + +**claim 36** — [FACT] `--continue --print` resumes the most recent conversation in non-interactive mode. [19] +> "for automation, use claude --continue --print 'prompt' to resume in non-interactive mode" + +**claim 37** — [FACT] resume preserves full message history — tool usage and results from the prior conversation are retained. [20] +> "the entire message history is restored to maintain context, tool usage and results from the previous conversation are preserved" + +**claim 38** — [FACT] headless mode (`-p`) does not persist sessions by default. [42] +> "headless mode does not persist between sessions" + +**claim 39** — [FACT] there is no CLI command to list session IDs — `--resume` only shows a fuzzy picker in interactive mode. the only way to find session UUIDs is to inspect `.jsonl` files in `~/.claude/projects/`. [21] +> "no CLI command to list session IDs — the only way to find session UUIDs is to manually inspect .jsonl files" + +**claim 40** — [FACT] session history was lost after auto-update from 2.0.44 to 2.0.49 — history data present but not accessible, no format migration. [22] +> "users lost access to entire session history after auto-update, with history data present but not accessible" + +**claim 41** — [FACT] `~/.claude.json` config file corrupts when multiple claude code processes access it at the same time. [23] +> "the ~/.claude.json config file becomes corrupted when multiple claude code CLI processes access it simultaneously" + +**claim 42** — [FACT] in high-concurrency environments (30+ concurrent sessions), severe config corruption with 14+ occurrences in 11 hours. [24] +> "critical data loss in multi-project environments with high concurrency (30+ concurrent sessions)" + +**claim 43** — [FACT] large session files (>50MB) cause claude code to become unresponsive with 90% RAM usage. [25] +> "claude code becomes completely unresponsive with 90% RAM usage with large .jsonl session transcript files" + +**claim 44** — [DECIDED] khlone will run many clones per zone for v0. config contention is a **not-yet-a-problem** — user-validated at 10+ parallel processes without observed corruption. `CLAUDE_CONFIG_DIR` per clone is the fallback mitigation if contention ever surfaces. see zoomin: `.z1.multi-clone-contention`. + +--- + +## section 6: stdin and process i/o concerns + +**claim 45** — [FACT] claude code spawns child processes for shell environment detection that inherit stdin and consume it, which can cause the main process to receive EOF. [26] +> "shell detection subprocesses inherit stdin and consume it, so the main process receives EOF" + +**claim 46** — [FACT] with piped stdin, the error "raw mode is not supported on the current process.stdin" can occur. [27] +> "the error 'raw mode is not supported on the current process.stdin' can occur with piped input" + +**claim 47** — [FACT] the Bash tool can hang when it reads from `/dev/stdin` in sandbox mode. [28] +> "when the Bash tool reads from /dev/stdin in sandbox mode, claude code hangs after the command completes" + +**claim 48** — [SUMP] dispatch mode (`-p --input-format stream-json`) avoids the raw mode error because it uses structured JSON protocol, not raw terminal input. but stdin consumption by shell detection (claim 45) may still affect the first read. + +--- + +## section 7: process lifecycle + +**claim 49** — [FACT] graceful shutdown: send SIGTERM first (request clean exit), then SIGKILL after a timeout for forced termination. [35] +> "process.terminate() sends a SIGTERM signal for graceful exit, and process.kill() sends a SIGKILL signal for immediate termination" + +**claim 50** — [FACT] docker uses a 10-second grace period between SIGTERM and SIGKILL. [36] +> "docker waits for a grace period (default 10 seconds) for the process to exit, then sends a SIGKILL" + +**claim 51** — [FACT] zombie processes retain their process table entry even after execution completes. the parent must call wait() or waitpid() to reap them. [37] +> "a zombie process is a process whose execution is completed but it still has an entry in the process table" + +**claim 52** — [FACT] orphan processes (parent terminated first) are adopted by init (PID 1). [38] +> "orphan processes are adopted by init (PID 1)" + +**claim 53** — [KHUE] should the BrainCli handle's kill() implementation use SIGTERM + 5s timeout + SIGKILL? or is a simpler SIGTERM-only approach sufficient for v0? + +--- + +## section 8: cost and metrics + +**claim 54** — [FACT] ccusage and goccc both extract cost data by parse of local JSONL session files — no real-time per-call cost API exists. [29][30] +> "ccusage is a CLI tool for analysis of claude code usage from local JSONL files" +> "goccc parses JSONL session logs, deduplicates stream responses, and calculates per-model API costs" + +**claim 55** — [FACT] the claude agent SDK provides a cost service with service-level breakdowns in USD, with track of token usage, web search, and code execution costs. [31] + +**claim 56** — [FACT] 2026 API rates: Sonnet at $3/$15 per million input/output tokens. Opus at $15/$75 per million tokens. [32] + +**claim 57** — [FACT] cache multipliers: 5-minute cache write = 1.25x base input rate. 1-hour cache write = 2x base. cache read = 0.1x base. [33] +> "cache write tokens are 1.25 times the base input token rate, cache read tokens are 0.1 times the base input token rate" + +**claim 58** — [FACT] opus 4.6 cached reads cost $0.50 per million tokens. cache write 1.25x, cache hit 0.1x base rate. [34] +> "cached reads cost $0.50 per million tokens" +> "cache write costs 1.25x base rate, but cache hits only cost 0.1x base rate" + +**claim 59** — [SUMP] the BrainCli supplier can compute per-call cost from token counts in the stream events x model-specific rates. no session-file parse needed. + +--- + +## section 9: CLI subprocess — the decided path + +**claim 60** — [DECIDED] the CLI subprocess is the only viable path for v0. the CLI wraps the agent SDK with: (a) tools (Read, Edit, Bash, Grep, etc.), (b) system prompts and behavior, (c) subscription support (Claude Max plans — no per-token API cost), (d) permission enforcement, (e) CLAUDE.md and role context. the bare SDK lacks all of these — it is an inference loop, not the full claude code experience. [2][3] + +**claim 61** — [CLOSED] moot — CLI is the decided path. the SDK question is settled. + +**claim 62** — [CLOSED] moot — no hybrid approach. CLI subprocess for both dispatch and interact mode. dispatch via `-p --output-format stream-json`. interact via `--resume` in interactive TUI mode. + +--- + +## summary of open questions + +| # | question | status | impact | +|---|---|---|---| +| 15 | SDK vs CLI subprocess for v0? | **DECIDED: CLI** | CLI wraps SDK with tools, prompts, subscription support | +| 22 | `@lydell/node-pty` vs `node-pty`? | **DECIDED: lydell** | smaller install, prebuilt binaries, interact mode only | +| 35 | is `--allowedTools` sufficient for read-only enforcement? | OPEN | security of ask mode | +| 44 | multi-clone config corruption risk? | **DECIDED: not-yet-a-problem** | validated at 10+ parallel, `CLAUDE_CONFIG_DIR` as fallback (zoomin z1) | +| 53 | SIGTERM + timeout + SIGKILL or SIGTERM-only? | OPEN | kill() implementation | +| 61 | can the SDK support raw terminal attach? | **CLOSED** | moot — CLI is the decided path | +| 62 | hybrid SDK (dispatch) + CLI (interact)? | **CLOSED** | moot — CLI for both modes | + +## companion zoomin research + +| zoomin | slug | topic | +|---|---|---| +| z1 | multi-clone-contention | contention when many concurrent clones share `~/.claude/` state | +| z2 | session-file-ram | large session file growth and RAM exhaustion (includes TODO z2.29: prune session artifacts for long-lived brains — future work, not v0) | diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.src new file mode 100644 index 0000000..1397af9 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.src @@ -0,0 +1,28 @@ +research the claims available in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) + +specifically +- what are the facts that we can discover, relevant to this wish & vision & criteria? +- what are the questions and assumptions we can websearch to find worldwide thoughts on? + +--- + +use web search to discover and research +- cite every claim +- number each citation +- clone exact quotes from each citation + +explicitly label each claim found from research as either +- a [FACT] = an indisputable, immutable truth +or +- a [SUMP] = an assumption, that someone has made, either explicitly or implicitly +or +- a [KHUE] = an open question, that we too should consider +or +- a [OPIN] = an opinion, a subjective declaration, that we should consider + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims.z1.multi-clone-contention.v1.i1.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims.z1.multi-clone-contention.v1.i1.md new file mode 100644 index 0000000..f337b08 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims.z1.multi-clone-contention.v1.i1.md @@ -0,0 +1,169 @@ +# block 0: BrainCli — zoomin: multi-clone contention + +> what could break when many concurrent claude code CLI processes share the same machine + +**status: NOT-YET-A-PROBLEM.** the user runs 10+ parallel claude processes routinely without observed corruption or contention. the issues documented below are sourced from community bug reports at extreme concurrency (30+ sessions) and may be version-specific or platform-specific. this research is captured for awareness — not as a blocker for v0. + +--- + +## context + +khlone will run many clones per zone for v0 — potentially 3-10+ concurrent `claude -p` processes per machine. each clone is a separate headless CLI process. they all share the same `$HOME` and the same `~/.claude/` directory by default. this zoomin researches what could break at that concurrency level and what mitigations exist if needed. + +--- + +## claim index + +### section 1: ~/.claude.json config corruption + +**claim z1.1** — [FACT] `~/.claude.json` is a single monolithic JSON file that stores OAuth tokens, MCP server configs, per-project state, and feature flag caches. every claude code process reads and writes to this same file without file locks or atomic writes. [1] + +**claim z1.2** — [FACT] concurrent write corruption follows a classic read-modify-write race: processes read stale data, modify in memory, then overwrite each other's changes. partial writes produce truncated or malformed JSON. [2] +> "Config file corrupted, reset to defaults: JSON Parse error: Unexpected EOF" + +**claim z1.3** — [FACT] corruption rate scales with concurrency. observed rates: [3] +- under 10 sessions: "rare but possible" +- 15-25 sessions: every 2-4 hours +- 30+ sessions: every 30-60 minutes + +**claim z1.4** — [FACT] one user documented 14 corruption events in 11 hours, with 12 corrupted files in a 28-minute burst. timestamps differed by 2-3ms — proof of a race condition. [3] +> "critical data loss in multi-project environments with high concurrency (30+ concurrent sessions)" + +**claim z1.5** — [FACT] when corruption occurs, the file is reset to defaults. all MCP server configs, project mappings, per-project trust dialogs, and recent prompts are lost. [4] + +**claim z1.6** — [FACT] no official fix exists as of february 2026. issues #15608, #18998, #3117, and #2810 all report this. all were closed as duplicates or went stale. [2][3][4] + +--- + +### section 2: OAuth token refresh race + +**claim z1.7** — [FACT] OAuth refresh tokens are single-use. when multiple processes try to refresh concurrently, only one succeeds — the others receive an invalid token error and prompt for browser re-auth. [5] +> "more concurrent processes = larger window for token expiry overlap = higher collision probability" + +**claim z1.8** — [FACT] at 7-12+ concurrent sessions, re-auth prompts appear multiple times per day. re-auth in one session can cascade, void tokens for all other sessions. [5] + +**claim z1.9** — [FACT] `ANTHROPIC_API_KEY` avoids the OAuth race entirely — it is a static credential that never expires, never needs refresh, and never writes to any credential file. [6] +> "for headless mode, set ANTHROPIC_API_KEY environment variable" + +**claim z1.10** — [DECIDED] khlone will use OAuth (not API key) for subscription/Max tier support. contention is not-yet-a-problem — user runs 10+ parallel processes without observed issues. `CLAUDE_CONFIG_DIR` per clone is the fallback if OAuth contention ever surfaces. + +--- + +### section 3: API rate limits (shared org pool) + +**claim z1.11** — [FACT] rate limits are enforced per organization, not per API key. all claude code processes that use the same anthropic account share a single rate limit pool. [7] + +**claim z1.12** — [FACT] opus 4.x tier limits (february 2026): [8] + +| tier | RPM | input tokens/min | output tokens/min | +|------|-----|-------------------|-------------------| +| tier 1 ($5) | 50 | 30,000 | 8,000 | +| tier 2 ($40) | 1,000 | 450,000 | 90,000 | +| tier 3 ($200) | 2,000 | 800,000 | 160,000 | +| tier 4 ($400) | 4,000 | 2,000,000 | 400,000 | + +**claim z1.13** — [FACT] the opus rate limit applies to combined traffic across opus 4.6, 4.5, 4.1, and 4. [8] + +**claim z1.14** — [SUMP] at tier 1, 50 RPM across 10 clones = 5 RPM per clone — severely constrained. at tier 2, 1,000 RPM across 10 clones = 100 RPM each — likely sufficient. output tokens per minute is the tighter constraint: 90,000 OTPM / 10 clones = 9,000 OTPM each. + +**claim z1.15** — [FACT] prompt cache hits do NOT count toward input token limits. cached system prompts and tool definitions effectively multiply throughput. [8] + +**claim z1.16** — [FACT] the API uses a token bucket algorithm with continuous replenishment rather than fixed-interval resets. short bursts can still trigger limits: "a rate of 60 RPM may be enforced as 1 request per second." [8] + +--- + +### section 4: resource contention (CPU + memory) + +**claim z1.17** — [FACT] per-process memory at idle: 270-370 MB. at active use: 491-578 MB. extended sessions: 700-780 MB or more. [9] + +**claim z1.18** — [FACT] per-process CPU at active use: 9-18%. 4 concurrent processes observed at 45.7% total CPU. [9] + +**claim z1.19** — [FACT] each process maps ~71 GB virtual memory (normal for V8, not physical RAM) and runs 13-14 threads. [10] + +**claim z1.20** — [FACT] a known defect (#22275) causes sustained ~100% CPU per instance even at idle — busy-poll defect in certain versions. [10] + +**claim z1.21** — [SUMP] for 10 concurrent clones at active use: expect ~5-6 GB RAM total (without session file bloat). at idle: ~3-4 GB. a 4+ core machine is recommended. + +--- + +### section 5: shared vs per-session files in ~/.claude/ + +**claim z1.22** — [FACT] the directory layout with contention risk: [1][11] + +| file | scope | contention risk | +|---|---|---| +| `~/.claude.json` | shared | **CRITICAL** — written by every process | +| `~/.claude/.credentials.json` | shared | **HIGH** — OAuth tokens, written on refresh | +| `~/.claude/history.jsonl` | shared | **MEDIUM** — append-only prompt log | +| `~/.claude/stats-cache.json` | shared | **MEDIUM** — usage metrics | +| `~/.claude/settings.json` | shared | **NONE** — read-only at runtime | +| `~/.claude/CLAUDE.md` | shared | **NONE** — read-only at runtime | +| `~/.claude/projects//.jsonl` | per-session | **NONE** — UUID-keyed, no collision | +| `~/.claude/todos/` | per-task-list | **LOW** — has `.lock` file protection | + +**claim z1.23** — [FACT] session JSONL files are UUID-keyed per session. concurrent processes that start fresh sessions write to different files — no direct collision on session data. [1] + +**claim z1.24** — [FACT] a feature request (#19364) proposes session lock files at `~/.claude/projects/{project}/{session}.lock` for orchestrators. no response from anthropic as of february 2026. [12] + +--- + +### section 6: mitigations + +**claim z1.25** — [FACT] **separate HOME directories** is the most effective mitigation. set a unique `$HOME` per clone so each gets its own `~/.claude.json` and credential store. eliminates all shared-state contention. [13] + +**claim z1.26** — [FACT] **container isolation** (docker or devcontainer per clone) provides complete filesystem, port, and process isolation. official anthropic docs endorse devcontainers for agent isolation. [14] +> "docker sandboxes run claude code and other agents unsupervised but safely" + +**claim z1.27** — [FACT] **git worktrees** isolate source code per branch but do NOT solve `~/.claude.json` corruption — all processes still share the same HOME. [15] +> "git worktrees isolate your source code, but agents still fight over the same resources" + +**claim z1.28** — [FACT] **ANTHROPIC_API_KEY env var** avoids the OAuth refresh race entirely. each clone can use the same key (limits are per-org) or different keys. [6] + +**claim z1.29** — [FACT] **external mutex** (flock, named semaphore) can serialize access to `~/.claude.json`. one user implemented this with `fcntl.flock()`. only protects against your own processes — not VS Code extension or other external instances. [3] + +**claim z1.30** — [DECIDED] khlone will use OAuth (not API key) for subscription/Max tier support. the user runs 10+ parallel processes without observed corruption. contention is a not-yet-a-problem for v0. + +**claim z1.31** — [FACT] `CLAUDE_CONFIG_DIR` is an undocumented but functional env var that relocates all of `~/.claude/` into a custom directory per process. this gives each clone its own `.claude.json`, `.credentials.json`, `projects/`, etc. — full filesystem isolation if ever needed. [16] + +**claim z1.32** — [SUMP] if contention ever surfaces, the mitigation path is: (a) set `CLAUDE_CONFIG_DIR` per clone for config isolation, (b) symlink `~/.claude/.credentials.json` so all clones share the same OAuth tokens (whoever refreshes first wins, the rest benefit). not needed for v0 — kept as a fallback. + +**claim z1.33** — [FACT] `~/.claude.json` writes cluster around session initialization (numStartups, feature flag caches, tip counters). it does NOT write on every API call or tool use. the hot window is boot time, not steady-state operation. [16] + +--- + +## contention risk summary + +**status: NOT-YET-A-PROBLEM.** user-validated at 10+ parallel processes. + +| vector | theoretical severity | observed severity | mitigation (if ever needed) | +|---|---|---|---| +| `~/.claude.json` corruption | CRITICAL (at 30+) | **NONE** (at 10+) | `CLAUDE_CONFIG_DIR` per clone | +| OAuth token refresh race | HIGH (theoretical) | **NONE** (at 10+) | symlink credentials file | +| API rate limits (shared org pool) | MEDIUM | MEDIUM | tier 3+ for 10 clones | +| memory (3-6 GB for 10 clones) | MEDIUM | MEDIUM | budget 500-700 MB per active clone | +| CPU (10-18% per active clone) | MEDIUM | MEDIUM | 4+ core machine recommended | +| `history.jsonl` / `stats-cache.json` | LOW | **NONE** | tolerable | +| session JSONL files | NONE | NONE | UUID-keyed, no collision | + +--- + +## citations + +| # | source | url | +|---|--------|-----| +| 1 | claude config file anatomy (gist) | https://gist.github.com/samkeen/dc6a9771a78d1ecee7eb9ec1307f1b52 | +| 2 | config corruption bug #15608 | https://github.com/anthropics/claude-code/issues/15608 | +| 3 | severe corruption bug #18998 | https://github.com/anthropics/claude-code/issues/18998 | +| 4 | config corruption bug #3117 | https://github.com/anthropics/claude-code/issues/3117 | +| 5 | OAuth refresh race #24317 | https://github.com/anthropics/claude-code/issues/24317 | +| 6 | claude code headless docs | https://code.claude.com/docs/en/headless | +| 7 | anthropic rate limits FAQ | https://support.anthropic.com/en/articles/8243635-our-approach-to-api-rate-limits | +| 8 | claude API rate limits | https://platform.claude.com/docs/en/api/rate-limits | +| 9 | claude code resource use #11122 | https://github.com/anthropics/claude-code/issues/11122 | +| 10 | claude code CPU/memory #14012 | https://github.com/anthropics/claude-code/issues/14012 | +| 11 | claude code memory docs | https://code.claude.com/docs/en/memory | +| 12 | session lock feature request #19364 | https://github.com/anthropics/claude-code/issues/19364 | +| 13 | parallel claude code agents (ona.com) | https://ona.com/stories/parallelize-claude-code | +| 14 | docker sandbox for claude code | https://www.docker.com/blog/docker-sandboxes-for-claude-code/ | +| 15 | git worktrees for parallel agents | https://devcenter.upsun.com/posts/git-worktrees-for-parallel-ai-agents/ | +| 16 | CLAUDE_CONFIG_DIR env var (#3833, #25762, binary analysis) | https://github.com/anthropics/claude-code/issues/3833 | diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims.z2.session-file-ram.v1.i1.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims.z2.session-file-ram.v1.i1.md new file mode 100644 index 0000000..5d348f1 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims.z2.session-file-ram.v1.i1.md @@ -0,0 +1,190 @@ +# block 0: BrainCli — zoomin: session file RAM exhaustion + +> what happens when session .jsonl files grow large amid persistent clone operation + +**status: LIKELY MITIGATED BY HEADLESS MODE.** the bug reports below are sourced from interactive TUI sessions. khlone's dispatch mode (`claude -p`) likely avoids the worst of these issues: (a) `progress` entries that cause 99.6% of file bloat are TUI artifacts — headless mode emits fewer or none, (b) the resume picker freeze is irrelevant since `-p` targets one session directly via `--resume `, (c) the memory leak may have a different profile in headless mode since there is no TUI state. this research is captured for awareness and for interact mode (talk mode), where the full TUI is active. + +--- + +## context + +khlone maintains long-lived headless claude code processes via `claude -p` with session continuation via `--resume`. over time, session `.jsonl` files could grow large and cause problems — RAM exhaustion, resume hangs, total system freeze. this zoomin researches the failure modes, thresholds, and mitigations — with the caveat that most evidence comes from interactive TUI sessions, not headless `-p` mode. + +--- + +## claim index + +### section 1: failure thresholds + +**claim z2.1** — [FACT] claude code becomes completely unresponsive with 90% RAM use when it encounters a session `.jsonl` file above ~50MB. the entire host system becomes sluggish or frozen, and the process must be force-killed. [1] +> "claude code becomes completely unresponsive with 90% RAM use with large .jsonl session transcript files" + +**claim z2.2** — [FACT] observed degradation thresholds: [1][2] + +| file size | behavior | +|---|---| +| <5 MB | normal | +| ~5 MB | borderline — may cause slowdowns | +| ~7 MB | risky — noticeable latency | +| ~16 MB | freezes at project initialization, prevents ALL use of that project directory | +| ~50 MB | consistent total freeze on resume | +| ~102 MB | total system freeze, 90% RAM, 8,366 lines in the JSONL | +| ~193 MB | 100% CPU freeze, resume picker hangs | +| ~3.8 GB | consumed 12.8 GB physical RAM on a 30 GB server | + +**claim z2.3** — [FACT] claude code loads the entire JSONL into memory on resume — no stream-based reads, no size guards, no timeout. the debug log shows the process hangs silently after auth with no further output. [2] +> "debug logs show the process hangs silently after auth, with no further log output — consistent with a synchronous full-file parse that blocks the event loop" + +**claim z2.4** — [FACT] the resume picker scans ALL `.jsonl` files in the project directory (not just the target session). a large file in the directory can freeze the TUI before the user even selects a session. [3] + +--- + +### section 2: session file growth — the bloat problem + +**claim z2.5** — [FACT] the dominant cause of bloat is NOT user/assistant messages. in a 5.27 GB session file, breakdown: [4] +- 1,739 `progress` entries: **5,396 MB (99.6%)** +- 56 user messages: **0.39 MB** +- 110 assistant messages: **0.25 MB** + +**claim z2.6** — [FACT] each `progress` entry embeds the full `normalizedMessages` array — the entire conversation history up to that point. duplication is cumulative: [5] +- early events: ~140 messages embedded, ~0.72 MB each +- mid-session events: ~500 messages, ~0.85 MB each +- late-session events: ~2,264 messages, ~1.44 MB each + +**claim z2.7** — [FACT] a second source of bloat: tool output. bash tool results that exceed the size threshold are saved to `tool-results/.txt` AND serialized into the JSONL — the preview-only behavior is not honored in the session file. [6] + +**claim z2.8** — [FACT] with progress-entry bloat stripped, a 5.27 GB file collapses to **0.67 MB** (188 lines). the actual conversation data for a substantial session is well under 1 MB. the bloat is caused entirely by redundant `progress` event duplication and unserialized tool outputs. [4] + +--- + +### section 3: session file format + +**claim z2.9** — [FACT] each `.jsonl` file lives at `~/.claude/projects//.jsonl`. each line is a JSON object with these fields: [7] +- `type`: one of `user`, `assistant`, `summary`, `progress` +- `message`: nested object with `role`, `content` (array of text/tool_use/tool_result blocks), `model`, `id` +- `uuid` / `parentUuid`: message lineage +- `sessionId`: session identifier +- `timestamp`: ISO-8601 +- `usage`: `{ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens }` + +**claim z2.10** — [FACT] tool results embed full text of `tool_result` content blocks — file reads, bash output, grep output — directly in the JSONL line. no external reference or truncation. [6][7] + +--- + +### section 4: memory model + +**claim z2.11** — [FACT] the RAM spike is caused by: (a) `JSON.parse` of multi-MB lines, (b) construction of the full `normalizedMessages` array from `progress` events, and (c) V8 heap pressure from many large text allocations. one report showed the process exceeded the V8 heap limit and crashed with SIGABRT. [1] + +**claim z2.12** — [FACT] recent improvement (partial): as of v2.1.x, memory for `--resume` was "reduced by 68% through stat-based load and progressive enrichment." the core issue — full session parse — persists for the actual target session. [8] + +**claim z2.13** — [FACT] separate from session file bloat, a memory leak causes gradual RAM growth over time: [9] +- fresh session: ~360 MB RSS +- after ~30 minutes: ~823 MB RSS (128% increase) +- growth is gradual, not sudden +- restart + resume restores normal baseline — the leak is in the node.js process lifecycle, not in context size + +--- + +### section 5: auto-compact behavior + +**claim z2.14** — [FACT] auto-compaction exists but operates on the API context window, NOT the session file. [10] +- triggers at ~83.5% of context window use (~167K tokens of a 200K window) +- reserves a fixed ~33K token buffer for the compaction summarization call +- sends the conversation history to the API for "intelligent summarization" +- replaces old messages in memory with a compact summary +- continues in the SAME session + +**claim z2.15** — [FACT] auto-compact reduces the in-memory context sent to the API. it does NOT compact, truncate, or reduce the on-disk `.jsonl` file. the JSONL only grows — it is append-only. [10] + +**claim z2.16** — [FACT] deadlock scenario: if a session is stopped before auto-compact fires and the accumulated context exceeds the model limit, resume fails with "Prompt is too long" — but the user cannot run `/compact` because the session must load first. the session becomes permanently inaccessible. [11] + +**claim z2.17** — [FACT] there is NO automatic session branch or episode restart when context fills. a feature request exists but is not yet implemented. [12] + +**claim z2.18** — [FACT] `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` env var (values 1-100) can force earlier compaction, but the 33K token buffer remains hardcoded. [8] + +**claim z2.19** — [FACT] what survives compaction in memory: file modification state, key decisions, current task context. what is lost: exact error messages, precise function signatures, architectural rationale details. [13] + +--- + +### section 6: mitigations + +**claim z2.20** — [FACT] simplest fix: move or delete oversized `.jsonl` files. [1][2] +```bash +find ~/.claude/projects/ -name "*.jsonl" -size +50M -exec ls -lh {} \; +mv .jsonl /tmp/ +``` + +**claim z2.21** — [FACT] strip `progress` entries achieves 99.99% size reduction (5.27 GB to 0.67 MB): [4][5] +```python +import json +with open(filepath) as f: + lines = [l for l in f if json.loads(l).get('type') != 'progress'] +with open(filepath, 'w') as f: + f.writelines(lines) +``` + +**claim z2.22** — [FACT] a dedicated tool called "cozempic" applies 13 composable prune strategies: mega-block trim (>32KB blocks), document deduplication, progress tick removal, tiered auto-guard via file watcher. [14] + +**claim z2.23** — [SUMP] session rotation — start fresh sessions proactively rather than resume — is the simplest approach. use CLAUDE.md for persistent context that survives across sessions. [10] + +**claim z2.24** — [SUMP] for khlone, the most practical mitigation is: (a) monitor session file size per clone, (b) strip `progress` entries on a schedule (or after each dispatch), (c) rotate sessions when file size exceeds a threshold (~10 MB), (d) restart clones periodically to counter the memory leak (claim z2.13). + +--- + +### section 7: impact on concurrent clones + +**claim z2.25** — [FACT] per-process idle memory: 270-370 MB. per-process with bloated session: 12-125 GB. [2][9][15] + +**claim z2.26** — [FACT] a memory leak (separate from session bloat) can cause a single process to consume 120+ GB RAM over 30-60 minutes. the linux OOM killer will terminate processes, and lost session context is unrecoverable. [15] + +**claim z2.27** — [SUMP] for 5 concurrent clones each with bloated sessions, aggregate RAM demand could easily exceed 50-60 GB. with the memory leak active, a single clone can consume the entire machine. + +**claim z2.28** — [SUMP] khlone needs per-clone memory guards: (a) RSS limit per process (e.g., via `ulimit` or cgroup), (b) periodic restart to counter memory leak, (c) session file size monitor with automatic prune/rotation. + +**claim z2.29** — [TODO] khlone may need to prune resumable session artifacts (the `.jsonl` files in `~/.claude/projects/`) to enable long-lived brains. even without `progress` entry bloat, append-only session files will grow over time with conversation data and tool output. a future prune/rotate mechanism — strip stale entries, rotate to fresh sessions at a size threshold, or compress the JSONL — will be needed to sustain brains that run for days or weeks. **not a v0 blocker** — headless mode growth is ~1 MB per 100+ exchanges, so the threshold is far away. flagged for future work. + +--- + +## headless mode (`-p`) impact assessment + +| concern | interactive TUI | headless `-p` | notes | +|---|---|---|---| +| `progress` entry bloat (99.6% of file size) | **severe** — TUI status bar writes | **likely minimal** — no TUI, fewer/no progress entries | needs empirical validation | +| resume picker freeze | **severe** — scans all JSONL files | **not applicable** — `-p` uses `--resume ` directly | eliminated | +| memory leak (~460 MB / 30 min) | **observed** | **unknown** — no reports from headless mode | may be TUI-specific | +| full-file load on `--resume` | **applies** | **applies** — same load path | mitigated if files stay small (no progress bloat) | +| auto-compact (context window) | **applies** | **applies** — same API context management | same behavior | +| session file append-only growth | **applies** | **applies** — but without progress entries, growth is ~1 MB per 100+ exchanges | manageable | + +## operational thresholds for khlone + +**note:** these thresholds are based on interactive-mode data. headless mode may have significantly better numbers due to absent `progress` entry bloat. + +| metric | safe | caution | danger | +|---|---|---|---| +| session file size | <5 MB | 5-10 MB | >16 MB (freeze risk) | +| per-clone RSS | <500 MB | 500-800 MB | >1 GB (leak active) | +| clone uptime (if leak applies) | <30 min | 30-60 min | >60 min | +| aggregate RAM (10 clones) | <5 GB | 5-8 GB | >10 GB | + +--- + +## citations + +| # | source | url | +|---|--------|-----| +| 1 | large session file freeze #21022 | https://github.com/anthropics/claude-code/issues/21022 | +| 2 | 3.8 GB session file crash #22365 | https://github.com/anthropics/claude-code/issues/22365 | +| 3 | session file freeze resume picker #22149 | https://github.com/anthropics/claude-code/issues/22149 | +| 4 | progress entry bloat analysis #18905 | https://github.com/anthropics/claude-code/issues/18905 | +| 5 | progress event duplication #22149 | https://github.com/anthropics/claude-code/issues/22149 | +| 6 | tool output bloat in session file #23948 | https://github.com/anthropics/claude-code/issues/23948 | +| 7 | session JSONL format analysis (duckdb) | https://liambx.com/blog/claude-code-log-analysis-with-duckdb | +| 8 | context buffer management guide | https://claudefa.st/blog/guide/mechanics/context-buffer-management | +| 9 | memory leak #22968 | https://github.com/anthropics/claude-code/issues/22968 | +| 10 | auto-compact FAQ | https://claudelog.com/faqs/what-is-claude-code-auto-compact/ | +| 11 | context too large deadlock #14472 | https://github.com/anthropics/claude-code/issues/14472 | +| 12 | auto session restart request #25695 | https://github.com/anthropics/claude-code/issues/25695 | +| 13 | context recovery hook guide | https://claudefa.st/blog/tools/hooks/context-recovery-hook | +| 14 | cozempic session pruner | https://github.com/Ruya-AI/cozempic | +| 15 | memory leak 120 GB #4953 | https://github.com/anthropics/claude-code/issues/4953 | diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md new file mode 100644 index 0000000..8f5252d --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md @@ -0,0 +1,610 @@ +# block 0: BrainCli — domain research + +> extant domain objects, operations, and relationships relevant to the BrainCli contract + claude supplier + +--- + +## domain objects + +### 1. extant from rhachet (published package, not re-declared) + +#### 1.1 BrainOutput + +**classification:** domain literal (immutable, identity by all properties) + +**source:** `rhachet` package → `domain.objects/BrainOutput` + +**shape:** +```ts +interface BrainOutput { + output: TOutput; + metrics: BrainOutputMetrics; + episode: BrainEpisode; + series: AsBrainOutputSeriesFor; +} +``` + +**conditional type:** `AsBrainOutputSeriesFor` returns `null` for `'atom'`, `BrainSeries` for `'repl'`. BrainCli operates at the `'repl'` grain — series is always `BrainSeries`, never null at the type level. + +**relevance to block 0:** the return type of `ask()` and `act()`. the claude supplier constructs this from nd-JSON stream events. + +--- + +#### 1.2 BrainOutputMetrics + +**classification:** domain literal + +**source:** `rhachet` package → `domain.objects/BrainOutputMetrics` + +**shape:** +```ts +interface BrainOutputMetrics { + size: { + tokens: { + input: number; + output: number; + cache: { get: number; set: number }; + }; + chars: { + input: number; + output: number; + cache: { get: number; set: number }; + }; + }; + cost: { + time: IsoDuration; + cash: { + total: IsoPrice; + deets: { input: IsoPrice; output: IsoPrice; cache: IsoPrice }; + } | null; + }; +} +``` + +**relevance to block 0:** populated by the claude supplier from `message_start` and `message_delta` stream events. token counts come from the event stream; cost is derived from tokens × model rates. `cost.cash` may be null if the supplier cannot compute cost. + +--- + +#### 1.3 BrainEpisode + +**classification:** domain literal + +**source:** `rhachet` package → `domain.objects/BrainEpisode` + +**shape:** +```ts +interface BrainEpisode { + hash: string; + exid: string | null; + exchanges: BrainExchange[]; +} +``` + +**relevance to block 0:** each `ask()` or `act()` call returns a BrainEpisode that captures the current context window. the claude supplier can derive this from the stream events or construct a minimal episode ref. + +--- + +#### 1.4 BrainSeries + +**classification:** domain literal + +**source:** `rhachet` package → `domain.objects/BrainSeries` + +**shape:** +```ts +interface BrainSeries { + hash: string; + exid: string | null; + episodes: BrainEpisode[]; +} +``` + +**relevance to block 0:** the cross-boot continuation ref. maps to claude's `session_id`. the handle stores the current series and passes it via `--resume` on reboot. khlone persists the series ref for crash recovery. + +--- + +#### 1.5 BrainExchange + +**classification:** domain literal + +**source:** `rhachet` package → `domain.objects/BrainExchange` + +**shape:** +```ts +interface BrainExchange { + hash: string; + input: string; + output: string; + exid: string | null; +} +``` + +**relevance to block 0:** atomic request-response pair. each `ask()` / `act()` dispatch produces one exchange within the episode. the claude supplier captures the prompt as input and the collected output text as output. + +--- + +#### 1.6 BrainGrain + +**classification:** type literal (union) + +**source:** `rhachet` package → `domain.objects/BrainGrain` + +**shape:** +```ts +type BrainGrain = 'atom' | 'repl'; +``` + +**relevance to block 0:** BrainCli operates at the `'repl'` grain — it has multi-turn capability with tool use. this discriminant controls whether `BrainOutput.series` is `BrainSeries` or `null`. + +--- + +#### 1.7 BrainSpec + +**classification:** domain literal + +**source:** `rhachet` package → `domain.objects/BrainSpec` + +**shape (abbreviated):** +```ts +interface BrainSpec { + cost: { + time: { speed: number }; + cash: { cache: { read: IsoPrice; write: IsoPrice }; input: IsoPrice; output: IsoPrice }; + }; + gain: { + size: { context: number; output: number }; + grades: { swe: number; mmlu: number; humaneval: number; gpqa: number }; + cutoff: string; + domain: string[]; + skills: string[]; + }; +} +``` + +**relevance to block 0:** the claude supplier config maps slug → BrainSpec. the `cost.cash` rates are used to derive `BrainOutputMetrics.cost.cash` from token counts. `gain.size.context` informs episode capacity. + +--- + +#### 1.8 BrainSupplierSlug + +**classification:** branded type (literal) + +**source:** `rhachet` package → `domain.objects/BrainSupplierSlug` + +**shape:** +```ts +type BrainSupplierSlug = string & { __brand: 'BrainSupplierSlug' }; +``` + +**pattern:** suffix after `rhachet-brains-` in the package name. examples: `'anthropic'`, `'opencode'`, `'xai'`. + +**factory:** `toBrainSupplierSlug(packageName: string): BrainSupplierSlug` + +**relevance to block 0:** the factory `genBrainCli` uses the supplier slug prefix from the brain slug to route to the correct supplier. for block 0, only `'anthropic'` is supported. + +--- + +#### 1.9 ContextCli + +**classification:** domain literal (context object) + +**source:** `rhachet` package → `domain.objects/ContextCli` + +**shape:** +```ts +interface ContextCli { + cwd: string; + gitroot: string; +} +``` + +**factory:** `genContextCli(input: { cwd: string }): Promise` + +**relevance to block 0:** the `cwd` context passed to `genBrainCli` may use or compose with ContextCli for path validation. the claude CLI process is spawned with `cwd` as its work directory. + +--- + +### 2. new to block 0 (declared in `_topublish/`) + +#### 2.1 BrainCli (interface) + +**classification:** domain interface (not a domain object — it's a contract, not a data shape) + +**target location:** `_topublish/rhachet/BrainCli.ts` + +**shape (revised from handoff contract):** +```ts +interface BrainCli { + ask(input: { prompt: string }): Promise; + act(input: { prompt: string }): Promise; + + memory: { series: BrainSeries | null }; + + executor: { + instance: { pid: number; mode: BrainCliMode } | null; + boot(input: { mode: BrainCliMode }): Promise; + kill(): void; + }; + + terminal: { + write(data: string): void; + resize(input: { cols: number; rows: number }): void; + onData(cb: (chunk: string) => void): void; + onExit(cb: (info: { code: number; signal: string | null }) => void): void; + }; +} +``` + +**surfaces:** +- `ask` / `act` — dispatch methods that return BrainOutput. the primary interface. +- `memory` — durable state that persists across process churn: series starts null, populated after first dispatch, preserved through crashes. always an object (never null itself) — the handle always has memory +- `executor` — process lifecycle: instance state + boot/kill methods. `executor.instance` is null before boot, `{ pid, mode }` while alive, null after exit/kill +- `terminal` — raw i/o: onData, onExit, write, resize + +**note:** this is the handle — the process behind it may churn. `instance` tracks the live process; `memory` tracks what survives across reboots. + +--- + +#### 2.2 BrainCliMode (type literal) + +**classification:** type literal (union) + +**target location:** inline on BrainCli or co-declared + +**shape:** +```ts +type BrainCliMode = 'dispatch' | 'interact'; +``` + +**relevance:** discriminant for boot target and current handle state. dispatch = headless nd-JSON structured i/o. interact = raw PTY byte relay. + +--- + +#### 2.3 AnthropicBrainCliSlug (branded type) + +**classification:** branded type (literal) + +**target location:** `_topublish/rhachet-brains-anthropic/BrainCli.config.ts` + +**shape:** +```ts +type AnthropicBrainCliSlug = 'claude@anthropic/claude/opus/v4.5' | /* other claude variants */; +``` + +**relevance:** the set of valid slug values for the anthropic supplier. the config map uses this as the key type. + +--- + +#### 2.4 AnthropicBrainCliConfig (config literal) + +**classification:** domain literal (static config) + +**target location:** `_topublish/rhachet-brains-anthropic/BrainCli.config.ts` + +**shape (inferred from vision + access research):** +```ts +interface AnthropicBrainCliConfig { + slug: AnthropicBrainCliSlug; + binary: string; // e.g., 'claude' + spec: BrainSpec; // model capabilities + rates + tools: { + ask: string[]; // read-only tools for --allowedTools + act: string[]; // full tools for --allowedTools + }; +} +``` + +**relevance:** maps a slug to the concrete CLI invocation parameters. `getOneDispatchArgs` and `getOneInteractArgs` consume this config. + +--- + +## domain operations + +### 3.1 operations in `_topublish/rhachet/` (contract layer) + +| operation | verb | type | input | output | purpose | +|---|---|---|---|---|---| +| `genBrainCli` | gen | factory | `{ slug }`, context: `{ cwd }` | `BrainCli` | route slug to supplier, return handle | + +**note:** `genBrainCli` has gen (findsert) semantics in name only — it always constructs a fresh handle. the "gen" prefix is appropriate because it encapsulates the slug → supplier lookup + handle construction. the handle itself is not persisted. + +--- + +### 3.2 operations in `_topublish/rhachet-brains-anthropic/` (supplier layer) + +| operation | verb | type | input | output | purpose | +|---|---|---|---|---|---| +| `genBrainCli` | gen | factory | `{ slug }`, context: `{ cwd }` | `BrainCli` | construct claude-specific handle with terminal + executor + ask/act | +| `getOneBrainOutputFromStreamJson` | getOne | parser | `{ stream: ReadableStream }` | `BrainOutput` | parse nd-JSON event stream into typed BrainOutput with metrics | +| `getOneDispatchArgs` | getOne | compute | `{ config, mode, series }` | `string[]` | compute CLI args for dispatch boot: `-p --input-format stream-json ...` | +| `getOneInteractArgs` | getOne | compute | `{ config, series }` | `string[]` | compute CLI args for interact boot: `--resume ` | + +**operation detail:** + +**`getOneBrainOutputFromStreamJson`** — the core parse operation. consumes nd-JSON events from stdout: +- extracts `input_tokens` from `message_start` +- accumulates text from `content_block_delta` +- extracts cumulative `output_tokens`, cache tokens from `message_delta` +- detects `message_stop` as completion signal (not process exit — CLI may hang) +- derives `cost.cash` from tokens × `BrainSpec.cost.cash` rates +- constructs `BrainEpisode` and updates `BrainSeries` from `session_id` + +**`getOneDispatchArgs`** — deterministic arg assembly: +- always includes: `-p`, `--output-format stream-json`, `--input-format stream-json`, `--verbose` +- ask mode: `--allowedTools Read,Grep,Glob,WebSearch,WebFetch` +- act mode: `--allowedTools Read,Grep,Glob,Edit,Write,Bash,WebSearch,WebFetch` +- if series: `--resume ` + +**`getOneInteractArgs`** — deterministic arg assembly: +- if series: `--resume ` +- no `-p` flag (interactive TUI mode) +- no `--allowedTools` (interactive mode uses its own permission model) + +--- + +### 3.3 operations on the BrainCli handle (instance methods) + +| operation | surface | mode constraint | input | output | purpose | +|---|---|---|---|---|---| +| `executor.boot` | executor | any | `{ mode: BrainCliMode }` | `void` | spawn or respawn process in target mode. updates `instance` and may update `memory.series` | +| `executor.kill` | executor | any (no-op if not booted) | none | `void` | terminate live process. sets `instance` to null | +| `ask` | dispatch | dispatch only | `{ prompt: string }` | `BrainOutput` | dispatch read-only task | +| `act` | dispatch | dispatch only | `{ prompt: string }` | `BrainOutput` | dispatch full-tool task | +| `terminal.write` | terminal | any | `data: string` | `void` | write raw data to process stdin | +| `terminal.resize` | terminal | any | `{ cols, rows }` | `void` | relay terminal dimensions | +| `terminal.onData` | terminal | any | `cb: (chunk) => void` | `void` | subscribe to output chunks | +| `terminal.onExit` | terminal | any | `cb: (info: { code, signal }) => void` | `void` | subscribe to process exit | + +--- + +## domain object relationships + +### 4.1 treestruct: decoration (composition hierarchy) + +``` +BrainOutput +├── .output: string (the text response) +├── .metrics: BrainOutputMetrics +│ ├── .size.tokens: { input, output, cache: { get, set } } +│ ├── .size.chars: { input, output, cache: { get, set } } +│ └── .cost: { time: IsoDuration, cash: { total, deets } | null } +├── .episode: BrainEpisode +│ ├── .hash: string +│ ├── .exid: string | null +│ └── .exchanges: BrainExchange[] +│ ├── .hash: string +│ ├── .input: string +│ ├── .output: string +│ └── .exid: string | null +└── .series: BrainSeries + ├── .hash: string + ├── .exid: string | null + └── .episodes: BrainEpisode[] +``` + +**note:** BrainOutput is the root composite. every `ask()` / `act()` call returns one. BrainEpisode is nested inside BrainSeries (series contains the chain of episodes). + +--- + +### 4.2 treestruct: BrainCli handle (interface surfaces) + +``` +BrainCli +├── .ask({ prompt }) → BrainOutput +├── .act({ prompt }) → BrainOutput +├── .memory: { series: BrainSeries | null } # durable — survives reboots +├── .executor +│ ├── .instance: { pid, mode } | null # ephemeral — the live process +│ ├── .boot({ mode }) → void +│ └── .kill() → void +└── .terminal + ├── .write(data) → void + ├── .resize({ cols, rows }) → void + ├── .onData(cb) → void + └── .onExit(cb: (info: { code, signal }) => void) → void +``` + +--- + +### 4.3 treestruct: supplier config (static data) + +``` +CONFIG_BY_CLI_SLUG: Record +├── 'claude@anthropic/claude/opus/v4.5' +│ ├── .slug +│ ├── .binary: 'claude' +│ ├── .spec: BrainSpec +│ └── .tools: { ask: string[], act: string[] } +└── (other claude variants) +``` + +--- + +### 4.4 dependency graph: operations → domain objects + +``` +genBrainCli (contract) +└── genBrainCli (anthropic supplier) + ├── reads: AnthropicBrainCliConfig (from CONFIG_BY_CLI_SLUG) + ├── constructs: BrainCli handle + │ ├── executor.boot + │ │ ├── calls: getOneDispatchArgs | getOneInteractArgs + │ │ ├── reads: AnthropicBrainCliConfig + │ │ ├── reads: handle.memory.series (for --resume) + │ │ └── writes: handle.executor.instance = { pid, mode } + │ ├── ask / act + │ │ ├── calls: getOneBrainOutputFromStreamJson + │ │ └── produces: BrainOutput + │ │ ├── contains: BrainOutputMetrics + │ │ ├── contains: BrainEpisode + │ │ │ └── contains: BrainExchange[] + │ │ └── contains: BrainSeries + │ └── terminal (onData, onExit, write, resize) + │ └── wraps: child_process stdout/stdin or node-pty handle + └── validates: slug against CONFIG_BY_CLI_SLUG keys +``` + +--- + +### 4.5 dependency direction + +``` +contract layer (_topublish/rhachet/) +├── BrainCli interface type +│ ├── depends on: BrainOutput (from rhachet) +│ ├── depends on: BrainSeries (from rhachet) +│ └── depends on: BrainEpisode (from rhachet) +└── genBrainCli factory + └── depends on: BrainCli interface + +supplier layer (_topublish/rhachet-brains-anthropic/) +├── genBrainCli factory +│ ├── depends on: BrainCli interface (from contract) +│ ├── depends on: AnthropicBrainCliConfig (local) +│ └── depends on: BrainOutput, BrainSeries, BrainEpisode (from rhachet) +├── getOneBrainOutputFromStreamJson +│ ├── depends on: BrainOutput (from rhachet) +│ ├── depends on: BrainOutputMetrics (from rhachet) +│ ├── depends on: BrainEpisode (from rhachet) +│ ├── depends on: BrainExchange (from rhachet) +│ ├── depends on: BrainSeries (from rhachet) +│ └── depends on: BrainSpec (from rhachet) — for cost derivation +├── getOneDispatchArgs +│ └── depends on: AnthropicBrainCliConfig (local) +└── getOneInteractArgs + └── depends on: AnthropicBrainCliConfig (local) +``` + +**direction:** contract layer → rhachet domain objects. supplier layer → contract layer + rhachet domain objects. no upward deps. no circular deps. + +--- + +## composition: how domain objects and operations support the wish + +### 5.1 spawn flow + +``` +caller → genBrainCli({ slug }, { cwd }) + → contract genBrainCli validates slug prefix + → routes to anthropic genBrainCli + → looks up AnthropicBrainCliConfig from CONFIG_BY_CLI_SLUG + → constructs BrainCli handle (instance=null, memory={ series: null }) + → returns handle to caller +``` + +**domain objects touched:** AnthropicBrainCliSlug, AnthropicBrainCliConfig, BrainCli + +--- + +### 5.2 boot + dispatch flow + +``` +caller → handle.executor.boot({ mode: 'dispatch' }) + → if live process: kill it + → getOneDispatchArgs({ config, mode: 'ask'|'act', series }) + → spawn child_process with computed args + → update handle: executor.instance={ pid, mode }, wire terminal hooks + +caller → handle.ask({ prompt }) + → write nd-JSON message to stdin + → getOneBrainOutputFromStreamJson({ stream: stdout }) + → parse message_start → extract input_tokens + → accumulate content_block_delta → collect text + → parse message_delta → extract output_tokens, cache tokens + → detect message_stop → signal complete + → derive cost from tokens × BrainSpec rates + → construct BrainExchange (prompt → output text) + → construct BrainEpisode (with exchange) + → construct or update BrainSeries (with session_id from events) + → construct BrainOutputMetrics + → construct BrainOutput + → update handle.memory.series + → return BrainOutput to caller +``` + +**domain objects touched:** AnthropicBrainCliConfig, BrainOutput, BrainOutputMetrics, BrainEpisode, BrainExchange, BrainSeries, BrainSpec + +--- + +### 5.3 crash recovery flow + +``` +process dies → terminal.onExit fires → handle.executor.instance = null +caller → handle.executor.boot({ mode: 'dispatch' }) + → handle.memory.series is non-null (preserved from prior dispatch) + → getOneDispatchArgs includes --resume + → spawn new process with prior session context + → update handle: executor.instance={ pid, mode='dispatch' } + → memory.series preserved — same ref +``` + +**domain objects touched:** BrainSeries (preserved across boot), AnthropicBrainCliConfig + +--- + +### 5.4 mode switch flow + +``` +caller → handle.executor.boot({ mode: 'interact' }) + → kill current dispatch process → handle.executor.instance = null + → getOneInteractArgs({ config, series: handle.memory.series }) + → spawn via node-pty with --resume + → update handle: executor.instance={ pid, mode='interact' } + → memory.series preserved + → terminal.onData now emits raw PTY bytes + → terminal.write now accepts raw keystrokes +``` + +**domain objects touched:** BrainSeries (for --resume), AnthropicBrainCliConfig + +--- + +## summary: domain inventory for block 0 + +### domain objects (9 total) + +| object | classification | source | role in block 0 | +|---|---|---|---| +| BrainOutput | literal | rhachet (extant) | return type of ask/act | +| BrainOutputMetrics | literal | rhachet (extant) | metrics within BrainOutput | +| BrainEpisode | literal | rhachet (extant) | context window ref within BrainOutput | +| BrainSeries | literal | rhachet (extant) | cross-boot continuation ref | +| BrainExchange | literal | rhachet (extant) | atomic request-response pair | +| BrainGrain | type union | rhachet (extant) | discriminant ('atom' vs 'repl') | +| BrainSpec | literal | rhachet (extant) | model capabilities + cost rates | +| BrainSupplierSlug | branded type | rhachet (extant) | supplier discriminant | +| ContextCli | literal | rhachet (extant) | cwd + gitroot context | + +### new types (4 total) + +| type | classification | target location | role in block 0 | +|---|---|---|---| +| BrainCli | interface | `_topublish/rhachet/` | the handle contract | +| BrainCliMode | type union | `_topublish/rhachet/` | 'dispatch' or 'interact' | +| AnthropicBrainCliSlug | branded type | `_topublish/rhachet-brains-anthropic/` | valid claude slug values | +| AnthropicBrainCliConfig | literal | `_topublish/rhachet-brains-anthropic/` | slug → CLI invocation config | + +### domain operations (5 total) + +| operation | verb | layer | purpose | +|---|---|---|---| +| genBrainCli (contract) | gen | contract | slug → supplier route → handle | +| genBrainCli (supplier) | gen | supplier | construct claude-specific handle | +| getOneBrainOutputFromStreamJson | getOne | supplier | parse nd-JSON → BrainOutput | +| getOneDispatchArgs | getOne | supplier | compute dispatch CLI args | +| getOneInteractArgs | getOne | supplier | compute interact CLI args | + +--- + +## citations + +| # | source | type | +|---|---|---| +| [1] | rhachet package types (`dist/domain.objects/`) in khlone `node_modules` | primary | +| [2] | BrainCli contract handoff (`.behavior/v2026_02_15.khlone-v0/3.4.blueprint.handoff.contract.braincli.v1.i1.md`) | prior | +| [3] | block 0 handoff (`.behavior/v2026_02_15.khlone-v0/3.5.decomposition.handoff.block_0.v1.i1.md`) | prior | +| [4] | block 0 vision (`.behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md`) | prior | +| [5] | block 0 blackbox criteria (`.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md`) | prior | +| [6] | block 0 access research (`.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md`) | prior | +| [7] | khlone-v0 domain distill (`.behavior/v2026_02_15.khlone-v0/3.2.distill.domain._.v1.i1.md`) | prior | +| [8] | rhachet sdk exports (`dist/contract/sdk.brains.d.ts`) | primary | diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.src new file mode 100644 index 0000000..c8435a8 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.src @@ -0,0 +1,42 @@ +research the domain available in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) + +specifically +- what are the domain objects that are involved with this wish + - entities + - events + - literals +- what are the domain operations + - getOne + - getAll + - setCreate + - setUpdate + - setDelete +- what are the relationships between the domain objects? + - is there a treestruct of decoration? + - is there a treestruct of common subdomains? + - are there dependencies? +- how do the domain objects and operations compose to support wish? + +--- + +use web search to discover and research +- cite every claim +- number each citation +- clone exact quotes from each citation + +focus on these sdk's for reference, if provided +- + +--- + +remember +- this is to research extant domain.objects & domain.entities +- at most, you can restructure the terms into $noun.$adj treestruct shape or declastruct shape +- no coinage of new terms is allowed though. that will be left for subsequent docs + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain.terms.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain.terms.v1.src new file mode 100644 index 0000000..6020d5e --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain.terms.v1.src @@ -0,0 +1,48 @@ +research the domain available in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) + +specifically +- what are the terms that we need to coin for the new domain.objects required to fulfill the above? + - entities + - events + - literals +- what are the relationships between the domain objects? + - is there a treestruct of decoration? + - is there a treestruct of common subdomains? + - are there dependencies? +- how do folks commonly talk about these domain.objects? + - use citations from websearch + + +ultimatelly, +- propose options for each of the new domain.objects, what should we call them? + +our objective is to +- maximize specificity => eliminate ambiguity & minimize confusion +- maximize intuition => eliminate friction & maximize adoption + +to create a ubiquitous language + +--- + +use web search to discover and research +- cite every claim +- number each citation +- clone exact quotes from each citation + +--- + +explicitly consider parallel concepts from other domains +- bluecollar +- healthcare +- recreation +- cullinary +etc + +the older the domain, the deeper the words + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain.terms.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.prod.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.prod.v1.src new file mode 100644 index 0000000..c987d8f --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.prod.v1.src @@ -0,0 +1,31 @@ +research the prod codepath patterns available in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md (if declared) + +specifically +- what are the current key patterns in this repo, that are relevant? +- how do they relate to the wish? +- which ones will we reuse? which ones will we extend? which ones will we replace? + - mark with + - [REUSE] + - [EXTEND] + - [REPLACE] + +--- + +focus exclusively on the production codepaths. ignore test codepaths + +note, this includes any infra that production codepaths depend on + +--- + +enumerate each pattern +- cite every claim +- number each citation +- clone exact quotes from each citation + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.prod.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.test.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.test.v1.src new file mode 100644 index 0000000..28d15bf --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.test.v1.src @@ -0,0 +1,31 @@ +research the test codepath patterns available in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md (if declared) + +specifically +- what are the current key patterns in this repo, that are relevant? +- how do they relate to the wish? +- which ones will we reuse? which ones will we extend? which ones will we replace? + - mark with + - [REUSE] + - [EXTEND] + - [REPLACE] + +--- + +focus exclusively on the test codepath patterns. ignore production codepath patterns + +note, this includes any infra that test codepaths depend on + +--- + +enumerate each pattern +- cite every claim +- number each citation +- clone exact quotes from each citation + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.test.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.oss.levers.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.oss.levers.v1.src new file mode 100644 index 0000000..db0ead4 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.oss.levers.v1.src @@ -0,0 +1,29 @@ +research the prod codepath patterns available in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md (if declared) + +specifically +- what are the open source tools that we can leverage to solve this? +- how can we use them? examples? +- are they maintained? are there examples of frontier dev shops who use them? +- which ones should we consider? +- pros and cons of each? + +--- + +focus exclusively on the production codepaths. ignore test codepaths + +note, this includes any infra that production codepaths depend on + +--- + +enumerate each pattern +- cite every claim +- number each citation +- clone exact quotes from each citation + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.oss.levers.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.references._.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.references._.v1.src new file mode 100644 index 0000000..b4cf7ba --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.references._.v1.src @@ -0,0 +1,20 @@ +research the references required in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) + +specifically +- what research could we reference to ground our thoughts and knowledge? +- what knowledge could we cite to prove our sources? +- what terms, concepts, demos do they establish that we can leverage to expand our knowledge & thought? + +--- + +enumerate each lesson +- cite every claim +- number each citation +- clone exact quotes from each citation + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.references._.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.templates._.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.templates._.v1.src new file mode 100644 index 0000000..5d13662 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.templates._.v1.src @@ -0,0 +1,20 @@ +research the templates available in order to fulfill +- this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md (if declared) + +specifically +- what are the key patterns from each template? +- how do they relate to the wish + +--- + +use web search or the gh api to enumerate the contents of the templates +- cite every claim +- number each citation +- clone exact quotes from each citation + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.templates._.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.2.distill.domain._.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.2.distill.domain._.v1.src new file mode 100644 index 0000000..0d6b11f --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.2.distill.domain._.v1.src @@ -0,0 +1,41 @@ +distill the declastruct domain.objects and domain.operations that would +- enable fulfillment of + - this wish .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md + - this vision .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) + - this criteria .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- given the research declared here + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md (if declared) + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md (if declared) + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md (if declared) + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain.terms.v1.i1.md (if declared) + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.prod.v1.i1.md (if declared) + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.test.v1.i1.md (if declared) + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.templates._.v1.i1.md (if declared) + +procedure +1. declare the usecases and envision the contract that would be used to fulfill the usecases +2. declare the domain.objects, domain.operations, and access.daos that would fulfill this, via the declastruct pattern in this repo + +--- + +specifically +- what are the domain objects that are involved with this wish + - entities + - events + - literals +- what are the domain operations + - getOne + - getAll + - setCreate + - setUpdate + - setDelete +- what are the relationships between the domain objects? + - is there a treestruct of decoration? + - is there a treestruct of common subdomains? + - are there dependencies? +- how do the domain objects and operations compose to support wish? + +--- + +emit into +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.2.distill.domain._.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.ext.auth-context.v1.proposal.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.ext.auth-context.v1.proposal.md new file mode 100644 index 0000000..cfc344c --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.ext.auth-context.v1.proposal.md @@ -0,0 +1,497 @@ +# extension: auth context for genBrainCli — blueprint proposal + +> supply auth credentials via context so the handle works in cicd (api key) and local dev (oauth) without ambient env vars + +--- + +## 1. the problem + +today, `genBrainCli` inherits auth from `process.env`. if `ANTHROPIC_API_KEY` is set, claude CLI uses it. if not, it falls back to oauth (interactive login). + +this is implicit: +- **cicd**: must set `ANTHROPIC_API_KEY` in the shell environment before spawn — no contract enforcement +- **tests**: must `source use.apikeys.sh` before run — easy to omit, silent failure +- **local dev**: works via oauth but the handle doesn't know or declare which auth method is active +- **multi-supplier**: when codex/opencode suppliers arrive, each will have different auth env vars — ambient env won't scale + +## 2. the solution + +make auth explicit via nested `context.brain.auth` with a generic record keyed by supplier name. + +### 2.1 contract-level context type + generic + +```ts +// src/_topublish/rhachet/ContextBrainAuth.ts + +/** + * .what = generic auth context for brain CLI processes + * .why = each supplier defines its own auth shape keyed by supplier name; + * callers can narrow the generic for compile-time safety + * + * .note = nested key context.brain.auth. (e.g., .anthropic, .openai) + * namespaces auth per supplier — can disambiguate later if needed + */ +export type ContextBrainAuth< + TBrainAuthSupply extends Record = Record, +> = { + brain?: { + auth?: TBrainAuthSupply; + }; +}; +``` + +```ts +// src/_topublish/rhachet/genBrainCli.ts + +export const genBrainCli = async < + TBrainAuthSupply extends Record = Record, +>( + input: { slug: string }, + context: { cwd: string } & ContextBrainAuth, +): Promise => { ... }; +``` + +the generic `TBrainAuthSupply`: +- **default**: `Record` — loose, supplier validates at runtime +- **typed callers** can narrow: `genBrainCli<{ anthropic: BrainAuthAnthropic }>(...)` — compile-time safety at the call site +- **multi-supplier callers** can compose: `genBrainCli<{ anthropic: BrainAuthAnthropic, openai: BrainAuthOpenai }>(...)` — each `BrainAuth$Supplier` imported from its own `rhachet-brains-$supplier` package +- **suppliers always validate** via type guards — the generic is a convenience for callers, not a trust boundary +- **`ContextBrainAuth`** is a reusable named type — composable via `&` intersection on any context +- **`brain.auth`** is the generic record — each supplier reads its own key (e.g., `context.brain.auth.anthropic`) + +### 2.2 supplier auth types + type guards + +each supplier owns its own auth shape and type guard. rhachet doesn't import or know about them. + +```ts +// src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.ts + +import { PickOne } from 'type-fns'; + +/** + * .what = auth shape for the anthropic brain CLI supplier + * .why = explicit auth — callers declare api key or oauth intent + */ +export type BrainAuthAnthropic = { + via: PickOne<{ + oauth: true; + apiKey: string; + }>; +}; + +/** + * .what = type guard for BrainAuthAnthropic + * .why = suppliers validate auth at runtime — no unsafe `as` cast + */ +export const isBrainAuthAnthropic = ( + input: unknown, +): input is BrainAuthAnthropic => { + if (!input || typeof input !== 'object') return false; + const candidate = input as Record; + if (!candidate.via || typeof candidate.via !== 'object') return false; + const via = candidate.via as Record; + return via.oauth === true || typeof via.apiKey === 'string'; +}; +``` + +future suppliers define their own shape + guard: + +```ts +// future: BrainAuthOpenai.ts +export type BrainAuthOpenai = { + via: PickOne<{ apiKey: string }>; +}; +export const isBrainAuthOpenai = (input: unknown): input is BrainAuthOpenai => { ... }; + +// future: BrainAuthXai.ts +export type BrainAuthXai = { + via: PickOne<{ apiKey: string }>; +}; +export const isBrainAuthXai = (input: unknown): input is BrainAuthXai => { ... }; +``` + +### 2.3 supplier factory validates via type guard + +the anthropic supplier factory reads `context.brain.auth.anthropic` and validates with its type guard: + +```ts +// src/_topublish/rhachet-brains-anthropic/genBrainCli.ts + +export const genBrainCli = async ( + input: { slug: string }, + context: { cwd: string } & ContextBrainAuth<{ anthropic?: BrainAuthAnthropic }>, +): Promise => { + // derive anthropic auth: use explicit context if provided, otherwise default to oauth + const authCandidate = context.brain?.auth?.['anthropic']; + const authAnthropic: BrainAuthAnthropic = (() => { + // no auth provided — default to oauth + if (!authCandidate) return assure({ via: { oauth: true } }, isBrainAuthAnthropic); + + // caller provided auth — validate shape via type guard, fail fast if invalid + if (!isBrainAuthAnthropic(authCandidate)) + throw new BadRequestError( + 'context.brain.auth.anthropic has invalid shape', + { auth: context.brain?.auth }, + ); + return authCandidate; + })(); + + // build spawn env from derived auth + const spawnEnv = (() => { + const { CLAUDECODE: _strip, ANTHROPIC_API_KEY: _stripKey, ...baseEnv } = + process.env; + + // api key mode + if (authAnthropic.via.apiKey) + return { ...baseEnv, ANTHROPIC_API_KEY: authAnthropic.via.apiKey }; + + // oauth mode — clean env, claude CLI handles its own oauth + return baseEnv; + })(); + + // ... rest of handle construction (spawnEnv captured in closure, used on every boot) +}; +``` + +### 2.4 contract factory passes context through + +```ts +// src/_topublish/rhachet/genBrainCli.ts + +export const genBrainCli = async < + TBrainAuthSupply extends Record = Record, +>( + input: { slug: string }, + context: { cwd: string } & ContextBrainAuth, +): Promise => { + const supplierSlug = getOneSupplierSlugFromBrainSlug({ slug: input.slug }); + + if (supplierSlug === 'anthropic') { + const { genBrainCli: genAnthropicBrainCli } = await import( + '../rhachet-brains-anthropic/genBrainCli' + ); + return genAnthropicBrainCli(input, context); + } + + throw new BadRequestError( + `unsupported brain supplier: '${supplierSlug}'`, + { slug: input.slug }, + ); +}; +``` + +rhachet is just a passthrough — it routes the slug and hands context to the supplier. no auth knowledge. + +### 2.5 caller ergonomics + +**typed caller (cicd / integration tests):** +```ts +const brain = await genBrainCli<{ anthropic: BrainAuthAnthropic }>( + { slug: 'claude@anthropic/claude/haiku' }, + { + cwd: '/path/to/repo', + brain: { auth: { anthropic: { via: { apiKey: process.env.ANTHROPIC_API_KEY! } } } }, + }, +); +``` + +**multi-supplier typed caller:** +```ts +import { BrainAuthAnthropic } from 'rhachet-brains-anthropic'; +import { BrainAuthOpenai } from 'rhachet-brains-openai'; + +const brain = await genBrainCli<{ anthropic: BrainAuthAnthropic; openai: BrainAuthOpenai }>( + { slug: 'claude@anthropic/claude/haiku' }, + { + cwd: '/path/to/repo', + brain: { + auth: { + anthropic: { via: { apiKey: process.env.ANTHROPIC_API_KEY! } }, + openai: { via: { apiKey: process.env.OPENAI_API_KEY! } }, + }, + }, + }, +); +``` + +**untyped caller (still works — supplier validates at runtime via type guard):** +```ts +const brain = await genBrainCli( + { slug: 'claude@anthropic/claude/haiku' }, + { + cwd: '/path/to/repo', + brain: { auth: { anthropic: { via: { apiKey: process.env.ANTHROPIC_API_KEY! } } } }, + }, +); +``` + +**local dev (oauth):** +```ts +const brain = await genBrainCli( + { slug: 'claude@anthropic/claude/haiku' }, + { + cwd: '/path/to/repo', + brain: { auth: { anthropic: { via: { oauth: true } } } }, + }, +); +``` + +**no auth (omit brain.auth entirely — supplier decides how to handle):** +```ts +const brain = await genBrainCli( + { slug: 'claude@anthropic/claude/haiku' }, + { cwd: '/path/to/repo' }, +); +``` + +--- + +## 3. file changes + +``` +src/_topublish/ + rhachet/ + [+] ContextBrainAuth.ts # generic ContextBrainAuth type + [~] genBrainCli.ts # context becomes { cwd } & ContextBrainAuth + [~] index.ts # re-export ContextBrainAuth + rhachet-brains-anthropic/ + [+] BrainAuthAnthropic.ts # supplier auth shape + isBrainAuthAnthropic type guard + [~] genBrainCli.ts # validate via type guard, build spawn env + [~] index.ts # export BrainAuthAnthropic + isBrainAuthAnthropic + __test__/ + [+] genContextBrainAuthAnthropic.ts # test helper: construct auth context from PickOne via + [~] genBrainCli.dispatch.ask.integration.test.ts + [~] genBrainCli.dispatch.act.integration.test.ts + [~] genBrainCli.dispatch.sequential.sameboot.integration.test.ts + [~] genBrainCli.dispatch.sequential.reboot.integration.test.ts + [~] genBrainCli.dispatch.withCompaction.integration.test.ts + [~] genBrainCli.interact.integration.test.ts + [~] genBrainCli.guards.integration.test.ts +``` + +2 new files. 9 modified files. 0 deleted files. + +--- + +## 4. test changes + +all 7 integration test files use a shared helper to construct the context: + +```ts +// src/_topublish/rhachet-brains-anthropic/__test__/genContextBrainAuthAnthropic.ts + +import { BrainAuthAnthropic } from '../BrainAuthAnthropic'; +import { ContextBrainAuth } from '../../rhachet/ContextBrainAuth'; + +/** + * .what = construct a ContextBrainAuth with anthropic auth + * .why = single place to build the test context — no repetition across 7 test files + */ +export const genContextBrainAuthAnthropic = ( + input: { via: BrainAuthAnthropic['via'] }, +): ContextBrainAuth<{ anthropic: BrainAuthAnthropic }> => ({ + brain: { auth: { anthropic: { via: input.via } } }, +}); +``` + +usage in tests: + +```ts +// shared context constant at top of each test file +const CONTEXT = { + cwd: CWD, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw('ANTHROPIC_API_KEY must be set via use.apikeys.sh'), + }, + }), +}; + +// before +const brain = await genBrainCli({ slug: SLUG_HAIKU }, { cwd: CWD }); + +// after +const brain = await genBrainCli({ slug: SLUG_HAIKU }, CONTEXT); +``` + +the `use.apikeys.sh` source step still loads the env var — but now the test explicitly passes it into the contract rather than reliant on ambient env inheritance. `UnexpectedCodePathError.throw` fails fast if the key is absent. + +### new guard tests (in genBrainCli.guards.integration.test.ts) + +```ts +given('[case3] auth validation', () => { + when('[t0] no brain.auth at all', () => { + then('it defaults to oauth (no error)', async () => { + const brain = await genBrainCli( + { slug: SLUG_HAIKU }, + { cwd: CWD }, + ); + expect(brain).toBeDefined(); + }); + }); + + when('[t1] brain.auth present but no anthropic key', () => { + then('it defaults to oauth (no error)', async () => { + const brain = await genBrainCli( + { slug: SLUG_HAIKU }, + { cwd: CWD, brain: { auth: {} } }, + ); + expect(brain).toBeDefined(); + }); + }); + + when('[t2] brain present but auth undefined', () => { + then('it defaults to oauth (no error)', async () => { + const brain = await genBrainCli( + { slug: SLUG_HAIKU }, + { cwd: CWD, brain: {} } as any, + ); + expect(brain).toBeDefined(); + }); + }); + + when('[t3] invalid auth shape for anthropic supplier', () => { + then('it throws a BadRequestError', async () => { + const error = await getError( + genBrainCli( + { slug: SLUG_HAIKU }, + { cwd: CWD, brain: { auth: { anthropic: { wrong: 'shape' } } } } as any, + ), + ); + expect(error).toBeInstanceOf(BadRequestError); + }); + }); + + when('[t4] anthropic key present but via is null', () => { + then('it throws a BadRequestError', async () => { + const error = await getError( + genBrainCli( + { slug: SLUG_HAIKU }, + { cwd: CWD, brain: { auth: { anthropic: { via: null } } } } as any, + ), + ); + expect(error).toBeInstanceOf(BadRequestError); + }); + }); +}); +``` + +### new unit tests for isBrainAuthAnthropic (in isBrainAuthAnthropic.test.ts) + +```ts +const TEST_CASES = [ + { + description: 'valid oauth shape', + given: { input: { via: { oauth: true } } }, + expect: { output: true }, + }, + { + description: 'valid apiKey shape', + given: { input: { via: { apiKey: 'sk-test-123' } } }, + expect: { output: true }, + }, + { + description: 'null input', + given: { input: null }, + expect: { output: false }, + }, + { + description: 'undefined input', + given: { input: undefined }, + expect: { output: false }, + }, + { + description: 'empty object', + given: { input: {} }, + expect: { output: false }, + }, + { + description: 'via is null', + given: { input: { via: null } }, + expect: { output: false }, + }, + { + description: 'via has wrong keys', + given: { input: { via: { wrong: 'shape' } } }, + expect: { output: false }, + }, + { + description: 'apiKey is not a text value', + given: { input: { via: { apiKey: 123 } } }, + expect: { output: false }, + }, + { + description: 'oauth is not true', + given: { input: { via: { oauth: 'yes' } } }, + expect: { output: false }, + }, +]; + +describe('isBrainAuthAnthropic', () => { + TEST_CASES.map((thisCase) => + test(thisCase.description, () => { + expect(isBrainAuthAnthropic(thisCase.given.input)).toEqual( + thisCase.expect.output, + ); + }), + ); +}); +``` + +--- + +## 5. design decisions + +| # | decision | rationale | +|---|----------|-----------| +| 1 | `context.brain.auth` nested record keyed by supplier | natural object access — each supplier reads its own key (e.g., `.anthropic`) | +| 2 | generic `TBrainAuthSupply` on `brain.auth` with default `Record` | typed callers get compile-time safety; untyped callers still work — suppliers validate at runtime regardless | +| 3 | auth record keyed by supplier name | each supplier grabs its own key — no collision, no contract-level knowledge of supplier auth shapes | +| 4 | `BrainAuth$Supplier` + `isBrainAuth$Supplier` owned by each supplier package | only the supplier cares about its auth shape — rhachet never imports it; type guard replaces unsafe `as` cast | +| 5 | `PickOne` on `via` within supplier auth | prevents `{ oauth: true, apiKey: 'sk-...' }` — exactly one method | +| 6 | strip ambient `ANTHROPIC_API_KEY` always | prevents parent env from leak into spawn — auth is only via explicit context | +| 7 | absent auth defaults to oauth; invalid auth fails fast via type guard + `BadRequestError` | absent = sensible default (local dev); present but malformed = caller mistake, fail fast | +| 8 | auth captured in closure like `cwd` | available on every `boot()` — no need to re-pass on reboot or mode switch | + +--- + +## 6. layered responsibility + +``` +caller + optionally narrows TBrainAuthSupply for compile-time safety + passes context.brain.auth.anthropic with credentials + +rhachet contract factory (genBrainCli) + accepts generic TBrainAuthSupply — no supplier auth knowledge + routes by slug to supplier + passes full context through + +supplier factory (anthropic/genBrainCli) + reads context.brain?.auth?.anthropic + if absent: defaults to oauth ({ via: { oauth: true } }) + if present: validates via isBrainAuthAnthropic type guard (BadRequestError if invalid) + translates auth to spawn env + captures in closure for every boot() +``` + +--- + +## 7. risk assessment + +| risk | likelihood | mitigation | +|------|-----------|------------| +| callers forget to pass auth | safe | defaults to oauth — local dev works out of the box | +| callers pass wrong auth shape | caught at runtime | type guard rejects — BadRequestError with auth context | +| typed callers get false confidence from generic | low | supplier always validates at runtime via type guard — defense in depth | +| verbose context at call sites | acceptable | the nested shape is explicit and self-evident; `genContextBrainAuthAnthropic` helper reduces repetition | + +--- + +## 8. scope + +this is a **narrow extension** — no new behavior, no new domain objects. the auth context is plumbed through the extant spawn path. + +3 new files (`ContextBrainAuth.ts`, `BrainAuthAnthropic.ts`, `genContextBrainAuthAnthropic.ts`), 9 modified files, 2 new guard tests. diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md new file mode 100644 index 0000000..d98918f --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md @@ -0,0 +1,837 @@ +# block 0: BrainCli contract + claude supplier — blueprint + +> how to implement the BrainCli interface and its first supplier (claude code CLI), tested live + +--- + +## 1. contract layer: `src/_topublish/rhachet/` + +### 1.1 BrainCli.ts — the interface type + +```ts +import { BrainOutput, BrainSeries } from 'rhachet'; + +/** + * .what = the handle contract for a headless brain CLI process + * .why = stable ref that survives process churn — the daemon codes against this + */ +export type BrainCliMode = 'dispatch' | 'interact'; + +export interface BrainCli { + ask(input: { prompt: string }): Promise; + act(input: { prompt: string }): Promise; + + memory: { series: BrainSeries | null }; + + executor: { + instance: { pid: number; mode: BrainCliMode } | null; + boot(input: { mode: BrainCliMode }): Promise; + kill(): void; + }; + + terminal: { + write(data: string): void; + resize(input: { cols: number; rows: number }): void; + onData(cb: (chunk: string) => void): void; + onExit(cb: (info: { code: number; signal: string | null }) => void): void; + }; +} +``` + +**design notes:** +- `ask` / `act` first — the primary interface +- `memory` — durable state. always an object (never null). `series` starts null, populated after first dispatch, preserved across reboots +- `executor.instance` — ephemeral. `{ pid, mode }` while alive, null otherwise +- `executor.boot` / `executor.kill` — pure lifecycle methods +- `terminal` — raw i/o surface for both modes +- `terminal.resize` uses `(input: { cols, rows })` not positional args +- `terminal.onExit` uses `(info: { code, signal })` not positional args + +### 1.2 genBrainCli.ts — the contract factory + +```ts +import { BadRequestError } from 'helpful-errors'; + +/** + * .what = route a brain slug to the correct supplier and return a BrainCli handle + * .why = dependency inversion — khlone never touches vendor CLI args + */ +export const genBrainCli = async < + TBrainAuthSupply extends Record = Record, +>( + input: { slug: string }, + context: { cwd: string } & ContextBrainAuth, +): Promise => { + // extract supplier prefix from slug (e.g., 'claude@anthropic/...' -> 'anthropic') + const supplierSlug = getOneSupplierSlugFromBrainSlug({ slug: input.slug }); + + // route to supplier + if (supplierSlug === 'anthropic') { + const { genBrainCli: genAnthropicBrainCli } = await import( + '../rhachet-brains-anthropic/genBrainCli' + ); + return genAnthropicBrainCli(input, context); + } + + // fail fast for unsupported suppliers + throw new BadRequestError( + `unsupported brain supplier: '${supplierSlug}' (from slug '${input.slug}')`, + { slug: input.slug, supplierSlug }, + ); +}; +``` + +**note:** `getOneSupplierSlugFromBrainSlug` is a pure text parse — extract the part between `@` and the next `/`. for `claude@anthropic/claude/opus/v4.5`, it returns `anthropic`. + +### 1.3 getOneSupplierSlugFromBrainSlug.ts — pure text parse + +```ts +/** + * .what = extract the supplier slug from a brain slug + * .why = the contract factory needs to route to the correct supplier without knowledge of slug internals + */ +export const getOneSupplierSlugFromBrainSlug = ( + input: { slug: string }, +): string => { + // slug format: '@/' + // e.g., 'claude@anthropic/claude/opus/v4.5' → 'anthropic' + const atIndex = input.slug.indexOf('@'); + if (atIndex === -1) + throw new BadRequestError('invalid brain slug: no @ separator', { slug: input.slug }); + + const afterAt = input.slug.slice(atIndex + 1); + const slashIndex = afterAt.indexOf('/'); + if (slashIndex === -1) + throw new BadRequestError('invalid brain slug: no / after supplier', { slug: input.slug }); + + return afterAt.slice(0, slashIndex); +}; +``` + +### 1.4 ContextBrainAuth.ts — generic auth context type + +```ts +/** + * .what = generic auth context for brain CLI processes + * .why = each supplier defines its own auth shape keyed by supplier name; + * callers can narrow the generic for compile-time safety + * + * .note = nested key context.brain.auth. (e.g., .anthropic, .openai) + * namespaces auth per supplier — can disambiguate later if needed + */ +export type ContextBrainAuth< + TBrainAuthSupply extends Record = Record, +> = { + brain?: { + auth?: TBrainAuthSupply; + }; +}; +``` + +### 1.5 index.ts — re-exports + +```ts +export { BrainCli, BrainCliMode } from './BrainCli'; +export { genBrainCli } from './genBrainCli'; +export { ContextBrainAuth } from './ContextBrainAuth'; +``` + +--- + +## 2. supplier layer: `src/_topublish/rhachet-brains-anthropic/` + +### 2.1 BrainCli.config.ts — slug-to-config map + +**slug format**: `@/` + +the CLI slug references the **atom** (model) directly — not the repl. the CLI replaces the repl: it supplies its own tool-use loop. so the slug portion after `@/` maps 1:1 to `AnthropicBrainAtomSlug` from `CONFIG_BY_ATOM_SLUG`. + +examples: +- `claude@anthropic/claude/opus/v4.5` — atom slug = `claude/opus/v4.5` +- `claude@anthropic/claude/haiku` — atom slug = `claude/haiku` (family alias, latest version) + +```ts +import { BrainSpec } from 'rhachet'; +import { type AnthropicBrainAtomSlug, CONFIG_BY_ATOM_SLUG } from 'rhachet-brains-anthropic/...'; + +export type AnthropicBrainCliSlug = + | 'claude@anthropic/claude/haiku' + | 'claude@anthropic/claude/haiku/v4.5' + | 'claude@anthropic/claude/sonnet' + | 'claude@anthropic/claude/sonnet/v4' + | 'claude@anthropic/claude/sonnet/v4.5' + | 'claude@anthropic/claude/opus' + | 'claude@anthropic/claude/opus/v4.5'; + +export interface AnthropicBrainCliConfig { + slug: AnthropicBrainCliSlug; + binary: string; + spec: BrainSpec; + tools: { + ask: string[]; + act: string[]; + }; +} + +export const CONFIG_BY_CLI_SLUG: Record = { + 'claude@anthropic/claude/opus/v4.5': { + slug: 'claude@anthropic/claude/opus/v4.5', + binary: 'claude', + spec: { /* BrainSpec for opus 4.5 — cost rates, context size, grades */ }, + tools: { + ask: ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'], + act: ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'Bash', 'WebSearch', 'WebFetch'], + }, + }, + 'claude@anthropic/claude/sonnet/v4.5': { + slug: 'claude@anthropic/claude/sonnet/v4.5', + binary: 'claude', + spec: { /* BrainSpec for sonnet 4.5 */ }, + tools: { + ask: ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'], + act: ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'Bash', 'WebSearch', 'WebFetch'], + }, + }, +}; +``` + +### 2.2 getOneDispatchArgs.ts — compute dispatch CLI args + +```ts +import { BrainSeries } from 'rhachet'; + +/** + * .what = compute CLI args for dispatch mode boot + * .why = deterministic arg assembly — tested in isolation + */ +export const getOneDispatchArgs = ( + input: { + config: AnthropicBrainCliConfig; + taskMode: 'ask' | 'act'; + series: BrainSeries | null; + }, +): string[] => { + const args: string[] = [ + '-p', + '--output-format', 'stream-json', + '--input-format', 'stream-json', + '--verbose', + '--allowedTools', input.config.tools[input.taskMode].join(','), + ]; + + // resume prior series if extant + if (input.series?.exid) { + args.push('--resume', input.series.exid); + } + + return args; +}; +``` + +**note:** `taskMode` is passed per-dispatch, not per-boot. the boot spawns the process; each ask/act call writes nd-JSON to stdin with the appropriate `--allowedTools`. however, since `--allowedTools` is a spawn-time arg (not per-message), the process must be respawned if task mode changes. this is handled by the handle internals — if the prior boot used `ask` tools and the caller now calls `act`, the handle kills and respawns with `act` tools. + +**revision consideration:** the simpler approach is to boot with the full `act` tool set and have `ask` enforce read-only at the prompt level (instruct the brain to not mutate). however, this is less safe. the current design — respawn on mode switch — is safer. the handle tracks the last-booted task mode and respawns only if it changes. + +### 2.3 getOneInteractArgs.ts — compute interact CLI args + +```ts +/** + * .what = compute CLI args for interact mode boot + * .why = deterministic arg assembly — tested in isolation + */ +export const getOneInteractArgs = ( + input: { + config: AnthropicBrainCliConfig; + series: BrainSeries | null; + }, +): string[] => { + const args: string[] = []; + + // resume prior series if extant + if (input.series?.exid) { + args.push('--resume', input.series.exid); + } + + return args; +}; +``` + +### 2.4 getOneBrainOutputFromStreamJson.ts — parse nd-JSON stream + +```ts +import { + BrainOutput, + BrainOutputMetrics, + BrainEpisode, + BrainExchange, + BrainSeries, + BrainSpec, +} from 'rhachet'; + +/** + * .what = parse nd-JSON event stream from claude `-p` into a typed BrainOutput + * .why = the core transform — turns raw vendor stream into the contract's return type + */ +export const getOneBrainOutputFromStreamJson = async ( + input: { + prompt: string; + stdout: NodeJS.ReadableStream; + spec: BrainSpec; + seriesPrior: BrainSeries | null; + }, +): Promise => { + // ... implementation: + // + // 1. line-buffer stdout into complete JSON lines + // 2. for each line, parse as JSON and dispatch by event type: + // - message_start → extract initial input_tokens, capture session_id + // - content_block_start → note new block + // - content_block_delta → accumulate text (type: 'text_delta') + // → accumulate partial json (type: 'input_json_delta') + // - content_block_stop → finalize block + // - message_delta → extract cumulative usage (output_tokens, cache tokens), stop_reason + // - message_stop → signal completion (do NOT wait for process exit — CLI may hang) + // - error → throw with context + // 3. derive cost from tokens x spec rates + // 4. construct BrainExchange, BrainEpisode, BrainSeries + // 5. construct BrainOutputMetrics + // 6. return BrainOutput +}; +``` + +**key edge cases (from claims research):** +- detect `message_stop` as completion — do not rely on process exit (claim: CLI may hang after final event) +- accumulate `partial_json` in `input_json_delta` chunks until `content_block_stop` before parse +- token counts in `message_delta` are cumulative per-turn, not incremental +- `session_id` comes from the response JSON — capture it for series construction + +### 2.5 genBrainCli.ts — the supplier factory + +this is the core file. it constructs the BrainCli handle with all surfaces wired. + +```ts +import { spawn } from 'child_process'; +import { BadRequestError, UnexpectedCodePathError } from 'helpful-errors'; +import { BrainSeries } from 'rhachet'; + +import { assure } from 'type-fns'; + +import { BrainCli, BrainCliMode } from '../rhachet/BrainCli'; +import { ContextBrainAuth } from '../rhachet/ContextBrainAuth'; +import { CONFIG_BY_CLI_SLUG, AnthropicBrainCliSlug } from './BrainCli.config'; +import { BrainAuthAnthropic, isBrainAuthAnthropic } from './BrainAuthAnthropic'; +import { getOneDispatchArgs } from './getOneDispatchArgs'; +import { getOneInteractArgs } from './getOneInteractArgs'; +import { getOneBrainOutputFromStreamJson } from './getOneBrainOutputFromStreamJson'; + +/** + * .what = construct a BrainCli handle for a claude code CLI process + * .why = the anthropic supplier — wires spawn, dispatch, terminal, and series management + */ +export const genBrainCli = async ( + input: { slug: string }, + context: { cwd: string } & ContextBrainAuth<{ anthropic?: BrainAuthAnthropic }>, +): Promise => { + // validate slug + const config = CONFIG_BY_CLI_SLUG[input.slug as AnthropicBrainCliSlug]; + if (!config) + throw new BadRequestError('unrecognized anthropic brain CLI slug', { + slug: input.slug, + valid: Object.keys(CONFIG_BY_CLI_SLUG), + }); + + // derive anthropic auth: use explicit context if provided, otherwise default to oauth + const authCandidate = context.brain?.auth?.['anthropic']; + const authAnthropic: BrainAuthAnthropic = (() => { + // no auth provided — default to oauth + if (!authCandidate) return assure({ via: { oauth: true } }, isBrainAuthAnthropic); + + // caller provided auth — validate shape via type guard, fail fast if invalid + if (!isBrainAuthAnthropic(authCandidate)) + throw new BadRequestError( + 'context.brain.auth.anthropic has invalid shape', + { auth: context.brain?.auth }, + ); + return authCandidate; + })(); + + // build spawn env from derived auth + const spawnEnv = (() => { + const { CLAUDECODE: _strip, ANTHROPIC_API_KEY: _stripKey, ...baseEnv } = + process.env; + + // api key mode + if (authAnthropic.via.apiKey) + return { ...baseEnv, ANTHROPIC_API_KEY: authAnthropic.via.apiKey }; + + // oauth mode — clean env, claude CLI handles its own oauth + return baseEnv; + })(); + + // mutable handle state + let instance: BrainCli['executor']['instance'] = null; + let series: BrainSeries | null = null; + let lastTaskMode: 'ask' | 'act' | null = null; + let childProcess: ReturnType | null = null; + let ptyProcess: /* @lydell/node-pty IPty */ | null = null; + + // event callback registries + const dataListeners: Array<(chunk: string) => void> = []; + const exitListeners: Array<(info: { code: number; signal: string | null }) => void> = []; + + // helper: wire child process events to terminal callbacks + const wireTerminalHooks = (/* process handle */) => { + // stdout.on('data') → fire all dataListeners + // on('exit') → set instance to null, fire all exitListeners + }; + + // the handle + const handle: BrainCli = { + // dispatch methods + ask: async (askInput) => { + // guard: must be in dispatch mode + if (!instance) + throw new UnexpectedCodePathError('cannot ask: no live process. call executor.boot first'); + if (instance.mode !== 'dispatch') + throw new UnexpectedCodePathError('cannot ask: handle is in interact mode. boot dispatch first'); + + // if prior boot used act tools, respawn with ask tools + if (lastTaskMode !== 'ask') { + await handle.executor.boot({ mode: 'dispatch' }); + lastTaskMode = 'ask'; + } + + // write nd-JSON message to stdin + // collect BrainOutput from stream + // update series + // return BrainOutput + }, + + act: async (actInput) => { + // guard: same as ask but for act mode + // if prior boot used ask tools, respawn with act tools + // write nd-JSON message to stdin + // collect BrainOutput from stream + // update series + // return BrainOutput + }, + + // durable state + memory: { + get series() { return series; }, + set series(v) { series = v; }, + }, + + // process lifecycle + executor: { + get instance() { return instance; }, + + boot: async (bootInput) => { + // kill extant process if alive + if (instance) handle.executor.kill(); + + if (bootInput.mode === 'dispatch') { + // spawn via child_process.spawn with pipe stdio + const args = getOneDispatchArgs({ + config, + taskMode: lastTaskMode ?? 'ask', + series, + }); + childProcess = spawn(config.binary, args, { + cwd: context.cwd, + env: spawnEnv, + stdio: ['pipe', 'pipe', 'pipe'], + }); + instance = { pid: childProcess.pid!, mode: 'dispatch' }; + wireTerminalHooks(/* childProcess */); + } + + if (bootInput.mode === 'interact') { + // spawn via @lydell/node-pty for raw PTY + const args = getOneInteractArgs({ config, series }); + // ptyProcess = ptySpawn(config.binary, args, { ... }) + instance = { pid: ptyProcess!.pid, mode: 'interact' }; + wireTerminalHooks(/* ptyProcess */); + } + }, + + kill: () => { + if (!instance) return; // no-op if not booted + // SIGTERM the process + // cleanup: set instance to null + // note: onExit will fire from the process exit handler + }, + }, + + // terminal i/o + terminal: { + write: (data) => { + if (!instance) + throw new UnexpectedCodePathError('cannot write: no live process'); + // dispatch mode: write to childProcess.stdin + // interact mode: write to ptyProcess + }, + resize: (resizeInput) => { + // interact mode: ptyProcess.resize(cols, rows) + // dispatch mode: no-op (no terminal to resize) + }, + onData: (cb) => { dataListeners.push(cb); }, + onExit: (cb) => { exitListeners.push(cb); }, + }, + }; + + return handle; +}; +``` + +**key implementation details:** + +1. **ask/act tool mode respawn** — `--allowedTools` is a spawn-time arg. the handle tracks `lastTaskMode`. if a caller calls `.ask()` after a prior `.act()` (or vice versa), the handle internally kills and respawns with the correct tool set. this is invisible to the caller. + +2. **nd-JSON multi-turn on live process** — in dispatch mode, each ask/act writes a nd-JSON message to stdin and reads the response stream from stdout. the process stays alive between calls. no respawn per task. + +3. **series capture** — `session_id` comes from the first `message_start` event. the handle captures it and constructs/updates `BrainSeries`. on subsequent boots, `--resume ` continues the prior session. + +4. **PTY for interact only** — dispatch uses `child_process.spawn` with `stdio: ['pipe', 'pipe', 'pipe']`. interact uses `@lydell/node-pty` for full terminal emulation. different spawn strategies, same terminal surface. + +5. **event listener registry** — `onData` and `onExit` register callbacks that persist across process reboots. when boot kills and respawns, the new process is wired to the same listener registry. callers register once, survive reboots. + +### 2.7 BrainAuthAnthropic.ts — supplier auth shape + type guard + +```ts +import { PickOne } from 'type-fns'; + +/** + * .what = auth shape for the anthropic brain CLI supplier + * .why = explicit auth — callers declare api key or oauth intent + */ +export type BrainAuthAnthropic = { + via: PickOne<{ + oauth: true; + apiKey: string; + }>; +}; + +/** + * .what = type guard for BrainAuthAnthropic + * .why = suppliers validate auth at runtime — no unsafe `as` cast + */ +export const isBrainAuthAnthropic = ( + input: unknown, +): input is BrainAuthAnthropic => { + if (!input || typeof input !== 'object') return false; + const candidate = input as Record; + if (!candidate.via || typeof candidate.via !== 'object') return false; + const via = candidate.via as Record; + return via.oauth === true || typeof via.apiKey === 'string'; +}; +``` + +### 2.8 index.ts — exports + +```ts +export { genBrainCli } from './genBrainCli'; +export { CONFIG_BY_CLI_SLUG, AnthropicBrainCliSlug, AnthropicBrainCliConfig } from './BrainCli.config'; +export { BrainAuthAnthropic, isBrainAuthAnthropic } from './BrainAuthAnthropic'; +``` + +--- + +## 3. test coverage + +### 3.1 unit tests + +| file | what it tests | +|---|---| +| `getOneSupplierSlugFromBrainSlug.test.ts` | pure text parse: valid slugs, absent `@`, absent `/` after supplier | +| `getOneDispatchArgs.test.ts` | deterministic arg assembly for ask/act modes, with/without series | +| `getOneInteractArgs.test.ts` | deterministic arg assembly with/without series | +| `getOneBrainOutputFromStreamJson.test.ts` | parse nd-JSON event stream into BrainOutput. test with synthetic stream data — message_start, content_block_delta, message_delta, message_stop events | +| `isBrainAuthAnthropic.test.ts` | type guard: data-driven caselist — valid oauth, valid apiKey, null, non-object, absent via, via with both keys, via with neither key | + +**unit test strategy:** +- `getOneSupplierSlugFromBrainSlug` — data-driven caselist: valid slugs extract correct supplier, absent `@` throws, absent `/` throws +- `getOneDispatchArgs` and `getOneInteractArgs` are pure compute — data-driven caselist tests with expected arg arrays +- `getOneBrainOutputFromStreamJson` — feed synthetic nd-JSON lines into a readable stream, assert BrainOutput shape. covers: token extraction, cost derivation, series construction, text accumulation, error event +- `isBrainAuthAnthropic` — data-driven caselist: each case is `{ description, input, expected: boolean }`. covers: `{ via: { oauth: true } }` → true, `{ via: { apiKey: 'sk-...' } }` → true, `null` → false, `{}` → false, `{ via: null }` → false, `{ via: { oauth: true, apiKey: 'sk-...' } }` → true (both keys), `{ via: {} }` → false (neither key) + +### 3.2 integration tests + +| file | what it tests | +|---|---| +| `genBrainCli.integration.test.ts` | all usecases against a live claude code CLI process | + +**integration test plan** (maps to blackbox criteria usecases 1-11): + +``` +given a valid claude slug and cwd + when genBrainCli is called + then it returns a BrainCli handle with instance=null and memory.series=null + +given a valid slug with explicit auth context (context.brain.auth.anthropic) + when genBrainCli is called with { slug } and { cwd, brain: { auth: { anthropic: { via: { apiKey } } } } } + then it returns a BrainCli handle that uses the provided api key for spawn env + +given a valid slug with no auth context (brain.auth omitted) + when genBrainCli is called with { slug } and { cwd } + then it returns a BrainCli handle that defaults to oauth + +given a valid slug with empty auth context (brain: {} or brain: { auth: undefined }) + when genBrainCli is called + then it returns a BrainCli handle that defaults to oauth + +given a valid slug with invalid auth shape (brain.auth.anthropic present but malformed) + when genBrainCli is called + then it throws a BadRequestError with the invalid auth shape in metadata + +given an invalid slug + when genBrainCli is called + then it throws BadRequestError + +given a fresh BrainCli handle + when executor.boot({ mode: 'dispatch' }) is called + then executor.instance is { pid: , mode: 'dispatch' } + +given a booted dispatch handle + when ask({ prompt: 'what is 2+2?' }) is called + then it returns BrainOutput + then output is a non-empty text + then metrics.size.tokens.input > 0 + then metrics.size.tokens.output > 0 + then metrics.cost.time is a duration + then memory.series is non-null + +given a booted dispatch handle + when act({ prompt: 'list files in cwd' }) is called + then it returns BrainOutput with non-zero tokens + +given a booted dispatch handle with series + when executor.kill() is called then executor.boot({ mode: 'dispatch' }) + then executor.instance has a new pid + then memory.series is preserved (same exid) + +given a booted dispatch handle with series + when executor.boot({ mode: 'interact' }) is called + then executor.instance.mode is 'interact' + then memory.series is preserved + +given a booted dispatch handle + when terminal.onData is registered and ask is called + then the callback fires with output chunks + +given a booted handle + when the process is killed externally + then terminal.onExit fires exactly once + +given a handle that is not booted + when ask({ prompt }) is called + then it throws + +given a handle booted in interact mode + when ask({ prompt }) is called + then it throws + +given two sequential ask calls on the same handle + when both return BrainOutput + then each BrainOutput has independent metrics (per-call, not cumulative) + then metrics.size.tokens.input and output are both > 0 for each + +given a booted dispatch handle + when terminal.write is called with raw data + then the data is written to the process stdin without error + +given a handle booted in interact mode + when the brain CLI emits terminal output + then terminal.onData fires with raw PTY bytes +``` + +**integration test notes:** +- tests run against a live `claude` binary — requires valid credentials on the machine +- use `genTempDir({ slug: 'braincli' })` from `test-fns` for the cwd — auto-cleanup of stale dirs, repo-local `.temp/` path, no os-specific temp dir deps +- use cheap prompts (`'what is 2+2?'`, `'respond with ok'`) to minimize token cost +- the mode switch test (dispatch -> interact -> dispatch) validates series preservation across all transitions +- `terminal.onData` test: register callback before ask, assert it fired at least once +- `terminal.onExit` test: boot, then kill, assert callback fired with code +- auth context: all integration tests use a shared `genContextBrainAuthAnthropic` helper + `CONTEXT` constant: + +```ts +import { UnexpectedCodePathError } from 'helpful-errors'; +import { genContextBrainAuthAnthropic } from '../genContextBrainAuthAnthropic'; + +const CWD = genTempDir({ slug: 'braincli' }); +const CONTEXT = { + cwd: CWD, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw( + 'ANTHROPIC_API_KEY must be set via use.apikeys.sh', + ), + }, + }), +}; +``` + +- `genContextBrainAuthAnthropic` constructs the nested auth context from a `BrainAuthAnthropic['via']` value: + +```ts +export const genContextBrainAuthAnthropic = ( + input: { via: BrainAuthAnthropic['via'] }, +): ContextBrainAuth<{ anthropic: BrainAuthAnthropic }> => ({ + brain: { auth: { anthropic: { via: input.via } } }, +}); +``` + +### 3.3 acceptance tests + +no acceptance tests for this block — block 0 is a library, not a CLI. acceptance tests apply at the khlone command level (blocks A+). + +--- + +## 4. filediff treestruct + +``` +src/ +└── _topublish/ + ├── rhachet/ # contract layer + │ ├── [+] BrainCli.ts # interface type + BrainCliMode + │ ├── [+] ContextBrainAuth.ts # generic auth context type + │ ├── [+] genBrainCli.ts # factory: slug -> supplier -> handle + │ ├── [+] getOneSupplierSlugFromBrainSlug.ts # pure text parse: slug -> supplier prefix + │ ├── [+] index.ts # re-exports + │ └── __test__/ + │ └── [+] getOneSupplierSlugFromBrainSlug.test.ts # unit: slug parse caselist + └── rhachet-brains-anthropic/ # supplier layer + ├── [+] BrainAuthAnthropic.ts # auth shape + isBrainAuthAnthropic type guard + ├── [+] BrainCli.config.ts # CONFIG_BY_CLI_SLUG + types + ├── [+] genBrainCli.ts # factory: slug -> claude handle (with auth derivation) + ├── [+] genContextBrainAuthAnthropic.ts # test helper: construct auth context from via shape + ├── [+] getOneBrainOutputFromStreamJson.ts # nd-JSON stream -> BrainOutput + ├── [+] getOneDispatchArgs.ts # compute dispatch CLI args + ├── [+] getOneInteractArgs.ts # compute interact CLI args + ├── [~] index.ts # add BrainCli + auth exports alongside extant hooks + └── __test__/ + ├── [+] genBrainCli.integration.test.ts # live CLI integration tests + ├── [+] getOneBrainOutputFromStreamJson.test.ts # unit: synthetic stream parse + ├── [+] getOneDispatchArgs.test.ts # unit: arg assembly + ├── [+] getOneInteractArgs.test.ts # unit: arg assembly + └── [+] isBrainAuthAnthropic.test.ts # unit: type guard caselist +``` + +**totals:** 13 prod files (+1 updated), 6 test files. 20 total. + +--- + +## 5. codepath treestruct + +``` +genBrainCli (contract) [+] create +├── getOneSupplierSlugFromBrainSlug [+] create +│ ├── parse: extract supplier between '@' and first '/' [+] create +│ └── guard: throw BadRequestError if absent '@' or '/' [+] create +└── genBrainCli (anthropic supplier) [+] create + ├── validates: slug against CONFIG_BY_CLI_SLUG [+] create + ├── derives: authAnthropic from context.brain?.auth? [+] create + │ ├── absent → default oauth via assure + isBrainAuthAnthropic [+] create + │ ├── present + valid → use as-is (validated by type guard) [+] create + │ └── present + invalid → BadRequestError (fail fast) [+] create + ├── builds: spawnEnv from derived auth [+] create + │ ├── apiKey mode → ANTHROPIC_API_KEY in env [+] create + │ └── oauth mode → clean env (CLI handles oauth) [+] create + ├── constructs: BrainCli handle [+] create + │ ├── handle.ask [+] create + │ │ ├── guard: instance must be dispatch mode [+] create + │ │ ├── respawn if lastTaskMode changed [+] create + │ │ ├── write nd-JSON to stdin [+] create + │ │ └── getOneBrainOutputFromStreamJson [+] create + │ │ ├── line-buffer stdout [+] create + │ │ ├── parse event types [+] create + │ │ ├── derive cost from tokens x spec [+] create + │ │ └── construct BrainOutput [+] create + │ ├── handle.act [+] create + │ │ └── (same as ask, different tool set) [+] create + │ ├── handle.executor.boot [+] create + │ │ ├── kill extant process [+] create + │ │ ├── dispatch mode: spawn via child_process [+] create + │ │ │ └── getOneDispatchArgs [+] create + │ │ ├── interact mode: spawn via @lydell/node-pty [+] create + │ │ │ └── getOneInteractArgs [+] create + │ │ └── wire terminal hooks to new process [+] create + │ ├── handle.executor.kill [+] create + │ ├── handle.terminal.write [+] create + │ ├── handle.terminal.resize [+] create + │ ├── handle.terminal.onData [+] create + │ └── handle.terminal.onExit [+] create + └── CONFIG_BY_CLI_SLUG [+] create + └── BrainSpec per slug [←] reuse from rhachet + +ContextBrainAuth [+] create (contract layer) +BrainAuthAnthropic + isBrainAuthAnthropic [+] create (supplier layer) +genContextBrainAuthAnthropic [+] create (test helper) +BrainOutput / BrainOutputMetrics / BrainEpisode / etc [←] reuse from rhachet +BrainSpec [←] reuse from rhachet +BadRequestError / UnexpectedCodePathError [←] reuse from helpful-errors +assure / PickOne [←] reuse from type-fns +``` + +--- + +## 6. dependencies + +### 6.1 new dev dependencies to install + +| package | version | purpose | +|---|---|---| +| `@lydell/node-pty` | latest | PTY spawn for interact mode | + +### 6.2 extant dependencies (no change) + +| package | source | used for | +|---|---|---| +| `rhachet` | devDeps | BrainOutput, BrainSeries, BrainSpec, BrainEpisode, BrainExchange types | +| `helpful-errors` | deps | BadRequestError, UnexpectedCodePathError | +| `type-fns` | deps | `assure` (runtime type guard assertion), `PickOne` (exactly-one-key union) | +| `domain-objects` | deps | DomainLiteral for config objects | +| `test-fns` | devDeps | given/when/then test structure | + +--- + +## 7. open design questions for implementation + +| # | question | recommendation | impact | +|---|---|---|---| +| 1 | ask/act tool mode: respawn per mode switch or boot with act tools always? | respawn per mode switch (safer — claim 35 notes allowedTools is the enforcement mechanism) | handle complexity | +| 2 | nd-JSON line buffer: use a library (e.g., `split2`) or roll custom? | roll custom — it's ~10 lines, avoids a dep | simplicity | +| 3 | should `getOneDispatchArgs` accept `taskMode` (per-dispatch) or should boot accept it? | boot accepts it — `--allowedTools` is a spawn-time arg, not a per-message arg | architecture | +| 4 | should `terminal.onExit` fire on kill? | yes — consistent with node child_process behavior. callers should not distinguish kill from crash at the terminal level | contract clarity | +| 5 | how to handle `--allowedTools` task mode enforcement since it is a spawn-time arg? | track `lastTaskMode` on the handle. if ask/act caller requests a different mode than the last boot, silently kill+respawn. the caller never knows. | transparency | + +--- + +## 8. risk awareness (from claims research) + +| risk | mitigation in this block | ref | +|---|---|---| +| CLI may hang after `message_stop` | detect `message_stop` as completion, do not wait for process exit | claim z2.3 | +| `--allowedTools` bypass (claim 35) | open question — enforce at CLI level, trust the vendor | claim 35 | +| session file growth over time | not addressed in v0 — future todo (claim z2.29) | z2 zoomin | +| multi-clone contention | not addressed — not-yet-a-problem at 10+ parallel (claim z1.30) | z1 zoomin | +| `@lydell/node-pty` platform support | prebuilt binaries for current platform only — matches khlone's single-machine model | claim 22 | + +--- + +## citations + +| # | source | type | +|---|---|---| +| [1] | block 0 handoff (`.behavior/v2026_02_15.khlone-v0/3.5.decomposition.handoff.block_0.v1.i1.md`) | prior | +| [2] | BrainCli contract handoff (`.behavior/v2026_02_15.khlone-v0/3.4.blueprint.handoff.contract.braincli.v1.i1.md`) | prior | +| [3] | block 0 vision (`.behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md`) | prior | +| [4] | blackbox criteria (`.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md`) | prior | +| [5] | blackbox matrix (`.behavior/v2026_02_20.v0p0-bhrain-cli/2.2.criteria.blackbox.matrix.md`) | prior | +| [6] | domain research (`.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md`) | prior | +| [7] | access research (`.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md`) | prior | +| [8] | claims research (`.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md`) | prior | +| [9] | z1 zoomin: multi-clone contention (`.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims.z1.multi-clone-contention.v1.i1.md`) | prior | +| [10] | z2 zoomin: session file RAM (`.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims.z2.session-file-ram.v1.i1.md`) | prior | diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.src new file mode 100644 index 0000000..9ede03e --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.src @@ -0,0 +1,68 @@ +propose a blueprint for how we can implement the wish described +- in .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md + +with the domain distillation declared +- in .behavior/v2026_02_20.v0p0-bhrain-cli/3.2.distill.domain._.v1.i1.md (if declared) + +follow the patterns already present in this repo + +--- + +enforce thorough test coverage for proof of behavior satisfaction +- unit tests for all domain logic +- integration tests for all repo <-> access boundaries (os, apis, sdks, daos, etc) +- integration tests for all end <-> end flows +- acceptance tests for core blackbox behaviors + +--- + +include a treestruct of filediffs + +**legend:** +- `[+] create` — file to create +- `[~] update` — file to update +- `[-] delete` — file to delete + + +--- + +include a treestruct of codepaths + +**legend:** +- `[+]` create — codepath to create +- `[~]` update — codepath to update +- `[○]` retain — codepath to retain +- `[-]` delete — codepath to delete +- `[←]` reuse — codepath to reuse from elsewhere +- `[→]` eject — codepath to decompose for reuse + + +--- + +remember, the purpose of the blueprint is to declare what the execution will adhere to + +we want to see +- what contracts will be used +- how domain.objects and domain.operations are decomposed and recomposed +- what the codepaths are, their ease of maintenance and readability + +--- + +reference the below for full context +- .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain.terms.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.prod.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.test.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.oss.levers.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.templates._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.2.distill.domain._.v1.i1.md (if declared) + +--- + +emit to .behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/4.1.roadmap.v1.i1.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/4.1.roadmap.v1.i1.md new file mode 100644 index 0000000..1b7e4d8 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/4.1.roadmap.v1.i1.md @@ -0,0 +1,457 @@ +# block 0: BrainCli contract + claude supplier — roadmap + +> ordered execution plan with dependencies, acceptance criteria, and verification at each step + +--- + +## phase 0: scaffold + deps + +**goal:** repo is ready to build — deps installed, directories created, types check clean + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 6 (dependencies) + +**checklist:** +- [ ] install `@lydell/node-pty` as dev dep (pinned version) +- [ ] confirm `test-fns` is at 1.15.0+ (needs `genTempDir`) +- [ ] create `src/_topublish/rhachet/` directory +- [ ] create `src/_topublish/rhachet/__test__/` directory +- [ ] create `src/_topublish/rhachet-brains-anthropic/__test__/` directory (if absent) +- [ ] confirm `npm run test:types` passes with no new errors + +**acceptance criteria:** +``` +given the repo after phase 0 + when npm run test:types is executed + then it passes with zero new errors + when @lydell/node-pty is imported in a .ts file + then it resolves without type errors +``` + +**verify:** `npm run test:types` + +--- + +## phase 1: contract types + +**goal:** the BrainCli interface and slug parse utility exist, are typed, and the slug parser is unit-tested + +**depends on:** phase 0 + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 1.1, 1.3, 1.4 (BrainCli, slug parse, ContextBrainAuth) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md` § 2.1, 2.2 (BrainCli + BrainCliMode shapes) + +**checklist:** +- [ ] create `src/_topublish/rhachet/BrainCli.ts` — interface type + BrainCliMode +- [ ] create `src/_topublish/rhachet/ContextBrainAuth.ts` — generic auth context type: `{ brain?: { auth?: TBrainAuthSupply } }` +- [ ] create `src/_topublish/rhachet/getOneSupplierSlugFromBrainSlug.ts` — pure text parse +- [ ] create `src/_topublish/rhachet/__test__/getOneSupplierSlugFromBrainSlug.test.ts` — data-driven caselist +- [ ] create `src/_topublish/rhachet/index.ts` — re-exports (BrainCli, BrainCliMode, ContextBrainAuth, genBrainCli placeholder) + +**acceptance criteria:** +``` +given a valid brain slug 'claude@anthropic/claude/opus/v4.5' + when getOneSupplierSlugFromBrainSlug is called + then it returns 'anthropic' + +given a slug with no '@' + when getOneSupplierSlugFromBrainSlug is called + then it throws BadRequestError + +given a slug with '@' but no '/' after the supplier + when getOneSupplierSlugFromBrainSlug is called + then it throws BadRequestError +``` + +**verify:** `npm run test:unit -- getOneSupplierSlugFromBrainSlug` + `npm run test:types` + +--- + +## phase 2: supplier config + +**goal:** the slug-to-config map exists with valid BrainSpec entries for each claude variant + +**depends on:** phase 0 + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 2.1, 2.7 (config map + auth shape) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md` § 1.7 (BrainSpec shape), § 2.3-2.4 (slug + config types) + +**checklist:** +- [ ] create `src/_topublish/rhachet-brains-anthropic/BrainCli.config.ts` + - [ ] declare `AnthropicBrainCliSlug` type + - [ ] declare `AnthropicBrainCliConfig` interface + - [ ] declare `CONFIG_BY_CLI_SLUG` with entries for opus/v4.5 and sonnet/v4.5 + - [ ] populate BrainSpec cost rates per model (from rhachet types) + - [ ] populate tools.ask and tools.act arrays per model +- [ ] create `src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.ts` + - [ ] declare `BrainAuthAnthropic` type — `{ via: PickOne<{ oauth: true; apiKey: string }> }` + - [ ] declare `isBrainAuthAnthropic` type guard — runtime validation, no unsafe `as` cast +- [ ] create `src/_topublish/rhachet-brains-anthropic/__test__/isBrainAuthAnthropic.test.ts` + - [ ] data-driven caselist: `{ via: { oauth: true } }` → true, `{ via: { apiKey: 'sk-...' } }` → true + - [ ] `null` → false, `{}` → false, `{ via: null }` → false, `{ via: {} }` → false + - [ ] edge: `{ via: { oauth: true, apiKey: 'sk-...' } }` → true (both keys valid per guard) + +**acceptance criteria:** +``` +given CONFIG_BY_CLI_SLUG + when accessed with 'claude@anthropic/claude/opus/v4.5' + then it returns a config with binary='claude', spec with cost rates, and ask/act tool arrays + +given CONFIG_BY_CLI_SLUG + when accessed with an invalid slug + then it returns undefined + +given { via: { oauth: true } } + when isBrainAuthAnthropic is called + then it returns true + +given { via: { apiKey: 'sk-test' } } + when isBrainAuthAnthropic is called + then it returns true + +given null or {} or { via: null } or { via: {} } + when isBrainAuthAnthropic is called + then it returns false +``` + +**verify:** `npm run test:types` + `npm run test:unit -- isBrainAuthAnthropic` + +--- + +## phase 3: arg assembly + +**goal:** dispatch and interact CLI args are computed deterministically and unit-tested + +**depends on:** phase 2 + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 2.2, 2.3 +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md` (CLI flags and stream format) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md` (claim 35: --allowedTools enforcement) + +**checklist:** +- [ ] create `src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.ts` +- [ ] create `src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.ts` +- [ ] create `src/_topublish/rhachet-brains-anthropic/__test__/getOneDispatchArgs.test.ts` — data-driven caselist +- [ ] create `src/_topublish/rhachet-brains-anthropic/__test__/getOneInteractArgs.test.ts` — data-driven caselist + +**acceptance criteria:** +``` +given ask mode with no prior series + when getOneDispatchArgs is called + then args include: -p, --output-format stream-json, --input-format stream-json, --verbose + then args include --allowedTools with read-only tools + then args do NOT include --resume + +given act mode with a prior series + when getOneDispatchArgs is called + then args include --allowedTools with full tool set + then args include --resume + +given no prior series + when getOneInteractArgs is called + then args is an empty array + +given a prior series + when getOneInteractArgs is called + then args include --resume +``` + +**verify:** `npm run test:unit -- getOneDispatchArgs` + `npm run test:unit -- getOneInteractArgs` + +--- + +## phase 4: stream parser + +**goal:** nd-JSON event stream from claude `-p` is parsed into a typed BrainOutput with metrics — unit-tested with synthetic data + +**depends on:** phase 2 + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 2.4 +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md` (nd-JSON event format, event types) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md` (claim z2.3: do not wait for process exit) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md` § 1.1-1.5 (BrainOutput, BrainOutputMetrics, BrainEpisode, BrainSeries, BrainExchange shapes) + +**checklist:** +- [ ] create `src/_topublish/rhachet-brains-anthropic/getOneBrainOutputFromStreamJson.ts` + - [ ] line-buffer stdout into complete JSON lines + - [ ] parse by event type: message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop, error + - [ ] accumulate text from content_block_delta (text_delta) + - [ ] accumulate partial json from input_json_delta until content_block_stop + - [ ] extract token counts from message_start (input) and message_delta (output, cache) + - [ ] capture session_id for series construction + - [ ] detect message_stop as completion (not process exit) + - [ ] derive cost.cash from tokens x BrainSpec rates + - [ ] construct BrainExchange, BrainEpisode, BrainSeries, BrainOutputMetrics, BrainOutput +- [ ] create `src/_topublish/rhachet-brains-anthropic/__test__/getOneBrainOutputFromStreamJson.test.ts` + - [ ] test with synthetic nd-JSON stream: full happy path (message_start → deltas → message_delta → message_stop) + - [ ] test token extraction (input > 0, output > 0) + - [ ] test cost derivation (tokens x spec rates) + - [ ] test series construction (session_id captured) + - [ ] test text accumulation across multiple content_block_delta events + - [ ] test error event throws with context + +**acceptance criteria:** +``` +given a synthetic nd-JSON stream with message_start, content_block_delta, message_delta, message_stop + when getOneBrainOutputFromStreamJson is called + then it returns a BrainOutput with non-empty output text + then metrics.size.tokens.input > 0 + then metrics.size.tokens.output > 0 + then metrics.cost.time is a valid duration + then episode is non-null with at least one exchange + then series has an exid derived from session_id + +given a synthetic nd-JSON stream with an error event + when getOneBrainOutputFromStreamJson is called + then it throws with the error context +``` + +**verify:** `npm run test:unit -- getOneBrainOutputFromStreamJson` + +--- + +## phase 5: supplier factory + +**goal:** the anthropic genBrainCli constructs a live BrainCli handle with all surfaces wired — ask, act, memory, executor, terminal + +**depends on:** phase 1, phase 2, phase 3, phase 4 + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 2.5, 2.7 (full code sketch + auth shape) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md` § 3.3 (handle operations table), § 5.1-5.4 (all flows) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md` (claim 35: tool mode enforcement, claim z2.3: message_stop completion) + +**checklist:** +- [ ] create `src/_topublish/rhachet-brains-anthropic/genBrainCli.ts` + - [ ] validate slug against CONFIG_BY_CLI_SLUG — throw BadRequestError if absent + - [ ] derive authAnthropic from `context.brain?.auth?.['anthropic']` — three-way: + - absent → default oauth via `assure({ via: { oauth: true } }, isBrainAuthAnthropic)` + - present + valid (passes `isBrainAuthAnthropic`) → use as-is + - present + invalid (fails type guard) → throw `BadRequestError` with auth shape in metadata + - [ ] build spawnEnv from derived auth: + - apiKey mode → set `ANTHROPIC_API_KEY` in env + - oauth mode → clean env (strip ambient keys, let CLI handle oauth) + - [ ] type signature: `context: { cwd: string } & ContextBrainAuth<{ anthropic?: BrainAuthAnthropic }>` + - [ ] declare mutable handle state: instance, series, lastTaskMode, childProcess, ptyProcess + - [ ] declare event callback registries: dataListeners, exitListeners + - [ ] implement wireTerminalHooks — wire stdout/exit to listener registries + - [ ] implement handle.ask — guard dispatch mode, respawn on task mode change, write nd-JSON, parse stream, update series + - [ ] implement handle.act — same as ask with act tool set + - [ ] implement handle.memory — getter/setter for series + - [ ] implement handle.executor.instance — getter + - [ ] implement handle.executor.boot — kill extant, spawn dispatch (child_process) or interact (node-pty), update instance, wire hooks. pass spawnEnv to spawn + - [ ] implement handle.executor.kill — SIGTERM, set instance null, no-op if not booted + - [ ] implement handle.terminal.write — dispatch: childProcess.stdin, interact: ptyProcess + - [ ] implement handle.terminal.resize — interact: ptyProcess.resize, dispatch: no-op + - [ ] implement handle.terminal.onData — push to dataListeners + - [ ] implement handle.terminal.onExit — push to exitListeners + +**acceptance criteria:** +``` +given a valid anthropic slug + when genBrainCli is called + then it returns a BrainCli handle + then handle.executor.instance is null + then handle.memory.series is null + +given an invalid slug not in CONFIG_BY_CLI_SLUG + when genBrainCli is called + then it throws BadRequestError + +given a valid slug with explicit auth context ({ brain: { auth: { anthropic: { via: { apiKey } } } } }) + when genBrainCli is called + then it returns a handle that uses the provided api key for spawn env + +given a valid slug with no auth context (brain.auth omitted) + when genBrainCli is called + then it returns a handle that defaults to oauth + +given a valid slug with invalid auth shape (brain.auth.anthropic present but malformed) + when genBrainCli is called + then it throws BadRequestError with the invalid auth shape in metadata +``` + +**verify:** `npm run test:types` (full integration tested in phase 8) + +--- + +## phase 6: contract factory + +**goal:** the contract-layer genBrainCli routes slugs to the correct supplier + +**depends on:** phase 1, phase 5 + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 1.2 + +**checklist:** +- [ ] create `src/_topublish/rhachet/genBrainCli.ts` + - [ ] call getOneSupplierSlugFromBrainSlug to extract supplier + - [ ] route 'anthropic' to dynamic import of supplier genBrainCli + - [ ] throw BadRequestError for unrecognized suppliers +- [ ] update `src/_topublish/rhachet/index.ts` — export genBrainCli + +**acceptance criteria:** +``` +given a slug with supplier prefix 'anthropic' + when contract genBrainCli is called + then it delegates to the anthropic supplier and returns a BrainCli handle + +given a slug with an unrecognized supplier prefix + when contract genBrainCli is called + then it throws BadRequestError with the invalid supplier in the message +``` + +**verify:** `npm run test:types` (full integration tested in phase 7) + +--- + +## phase 7: supplier index + +**goal:** the anthropic supplier package exports are wired + +**depends on:** phase 5 + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 2.6 + +**checklist:** +- [ ] update `src/_topublish/rhachet-brains-anthropic/index.ts` — add genBrainCli, CONFIG_BY_CLI_SLUG, type exports, BrainAuthAnthropic, isBrainAuthAnthropic alongside extant hooks + +**acceptance criteria:** +``` +given the updated index.ts + when imported from another module + then genBrainCli, CONFIG_BY_CLI_SLUG, AnthropicBrainCliSlug, AnthropicBrainCliConfig, BrainAuthAnthropic, isBrainAuthAnthropic are available +``` + +**verify:** `npm run test:types` + +--- + +## phase 8: integration tests + +**goal:** all 11 blackbox usecases are verified against a live claude code CLI process + +**depends on:** phase 6, phase 7 + +**read before start:** +- `.behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md` (all 11 usecases — the source of truth for what to test) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md` § 3.2 (integration test plan + notes) +- `.behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md` § 1.1-1.5 (BrainOutput shape for assertions) + +**checklist:** +- [ ] create `src/_topublish/rhachet-brains-anthropic/genContextBrainAuthAnthropic.ts` — test helper that constructs auth context from `BrainAuthAnthropic['via']` +- [ ] create shared `CONTEXT` constant in each integration test file: + ```ts + const CONTEXT = { + cwd: CWD, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw('ANTHROPIC_API_KEY must be set via use.apikeys.sh'), + }, + }), + }; + ``` +- [ ] create `src/_topublish/rhachet-brains-anthropic/__test__/genBrainCli.integration.test.ts` +- [ ] use `genTempDir({ slug: 'braincli' })` from `test-fns` for the cwd +- [ ] test usecase.1: spawn handle from valid slug — instance=null, memory.series=null +- [ ] test usecase.1: invalid slug throws BadRequestError +- [ ] test usecase.1: valid slug with explicit auth (apiKey via CONTEXT) — handle returned, uses api key +- [ ] test usecase.1: valid slug with no auth context — handle returned, defaults to oauth +- [ ] test usecase.1: valid slug with empty auth context (brain: {} or brain: { auth: undefined }) — handle returned, defaults to oauth +- [ ] test usecase.1: valid slug with invalid auth shape — throws BadRequestError +- [ ] test usecase.2: boot dispatch — executor.instance is `{ pid: , mode: 'dispatch' }` +- [ ] test usecase.3: ask returns BrainOutput — non-empty output, tokens > 0, series non-null +- [ ] test usecase.4: act returns BrainOutput — non-zero tokens +- [ ] test usecase.5: terminal.onData fires with chunks amid dispatch ask +- [ ] test usecase.5: terminal.onData fires with raw PTY bytes in interact mode +- [ ] test usecase.6: terminal.onExit fires exactly once on kill +- [ ] test usecase.7: kill is no-op when not booted (no error) +- [ ] test usecase.8: crash recovery — kill + reboot preserves series (same exid), new pid +- [ ] test usecase.9: mode switch — dispatch -> interact preserves series, mode updated +- [ ] test usecase.10: terminal.write in dispatch mode writes to stdin without error +- [ ] test usecase.11: sequential ask calls — each BrainOutput has independent metrics (per-call, not cumulative) + +**acceptance criteria:** +``` +given a live claude binary with valid credentials + when the full integration test suite runs + then all usecase tests pass + then token counts are > 0 for ask and act + then series.exid is preserved across kill + reboot + then series.exid is preserved across dispatch -> interact mode switch + then onExit fires exactly once per kill + then sequential ask metrics are independent + +given a valid slug with explicit auth context (apiKey) + when genBrainCli is called via CONTEXT + then it returns a handle that uses the provided api key + +given a valid slug with no auth context + when genBrainCli is called with just { cwd } + then it returns a handle (defaults to oauth) + +given a valid slug with invalid auth shape + when genBrainCli is called + then it throws BadRequestError with the invalid auth shape in metadata +``` + +**verify:** `npm run test:integration -- genBrainCli.integration` + +--- + +## phase 9: final verification + +**goal:** all tests pass, types check clean, no regressions + +**depends on:** all prior phases + +**checklist:** +- [ ] `npm run test:types` — zero errors +- [ ] `npm run test:unit` — all unit tests pass (slug parse, dispatch args, interact args, stream parser, isBrainAuthAnthropic) +- [ ] `npm run test:integration -- genBrainCli.integration` — all integration tests pass (all 11 usecases + auth guard cases) +- [ ] review file inventory against blueprint filediff treestruct — confirm 13 prod (+1 updated) + 6 test = 20 files + +**acceptance criteria:** +``` +given the completed block 0 implementation + when all test suites run + then zero failures across unit, integration, and type checks + when the file inventory is audited + then it matches the blueprint filediff treestruct (20 files) +``` + +**verify:** `npm run test:types && npm run test:unit && npm run test:integration -- genBrainCli.integration` + +--- + +## dependency graph + +``` +phase 0: scaffold + deps +├── phase 1: contract types (BrainCli.ts, slug parse + test) +│ ├── phase 6: contract factory (genBrainCli router) +│ └─┐ +├── phase 2: supplier config (CONFIG_BY_CLI_SLUG) +│ ├── phase 3: arg assembly (dispatch + interact args + tests) +│ └── phase 4: stream parser (nd-JSON → BrainOutput + test) +│ └─┐ +│ phase 5: supplier factory (anthropic genBrainCli) +│ ├── phase 7: supplier index (exports) +│ └── phase 6: contract factory +│ └── phase 8: integration tests (all 11 usecases) +│ └── phase 9: final verification +``` + +**parallelizable:** +- phase 1 + phase 2 (no deps on each other) +- phase 3 + phase 4 (both depend on phase 2, independent of each other) + +**critical path:** phase 0 → phase 2 → phase 4 → phase 5 → phase 8 → phase 9 diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/4.1.roadmap.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/4.1.roadmap.v1.src new file mode 100644 index 0000000..e009dfd --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/4.1.roadmap.v1.src @@ -0,0 +1,39 @@ +declare a roadmap, + +- checklist style +- with ordered dependencies +- with behavioral acceptance criteria +- with behavioral acceptance verification at each step + +for how to execute the blueprint specified in .behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md + +ref: +- .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.access._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.claims._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.prod.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.test.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.oss.levers.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.templates._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.2.distill.domain._.v1.i1.md (if declared) + +--- + +be clear as to which briefs should be read before each phase + +for example, +- if the phase includes tests, remind the builder to read + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.patterns._.code.test.v1.i1.md (if declared) +- if the phase includes acceptance tests, remind the builder to read + - .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- if the phase includes domain.objects, remind the builder to read + - .behavior/v2026_02_20.v0p0-bhrain-cli/3.1.research.domain._.v1.i1.md (if declared) +etc + +--- + +emit into .behavior/v2026_02_20.v0p0-bhrain-cli/4.1.roadmap.v1.i1.md diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/5.1.execution.phase0_to_phaseN.v1.i1.md b/.behavior/v2026_02_20.v0p0-bhrain-cli/5.1.execution.phase0_to_phaseN.v1.i1.md new file mode 100644 index 0000000..5ef8f12 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/5.1.execution.phase0_to_phaseN.v1.i1.md @@ -0,0 +1,78 @@ +# block 0: BrainCli — execution tracker + +## phase 0: scaffold + deps +- [x] install `@lydell/node-pty` as dev dep (pinned version) — @lydell/node-pty@1.1.0 +- [x] confirm `test-fns` is at 1.15.0+ (needs `genTempDir`) — test-fns@1.15.0 +- [x] create `src/_topublish/rhachet/` directory +- [x] create `src/_topublish/rhachet/__test__/` directory +- [x] create `src/_topublish/rhachet-brains-anthropic/__test__/` directory +- [x] confirm `npm run test:types` passes with no new errors + +## phase 1: contract types +- [x] create `src/_topublish/rhachet/BrainCli.ts` +- [x] create `src/_topublish/rhachet/ContextBrainAuth.ts` — generic auth context type +- [x] create `src/_topublish/rhachet/getOneSupplierSlugFromBrainSlug.ts` +- [x] create `src/_topublish/rhachet/__test__/getOneSupplierSlugFromBrainSlug.test.ts` +- [x] create `src/_topublish/rhachet/__test__/ContextBrainAuth.typetest.test.ts` — positive + negative ts-expect-error assertions +- [x] create `src/_topublish/rhachet/index.ts` +- [x] update `src/_topublish/rhachet/index.ts` — add ContextBrainAuth export + +## phase 2: supplier config +- [x] create `src/_topublish/rhachet-brains-anthropic/BrainCli.config.ts` +- [x] create `src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.ts` — type + isBrainAuthAnthropic type guard +- [x] create `src/_topublish/rhachet-brains-anthropic/__test__/isBrainAuthAnthropic.test.ts` — data-driven caselist (10 cases) +- [x] create `src/_topublish/rhachet-brains-anthropic/__test__/BrainAuthAnthropic.typetest.test.ts` — positive + negative ts-expect-error assertions (11 cases) + +## phase 3: arg assembly +- [x] create `src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.ts` +- [x] create `src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.ts` +- [x] create `src/_topublish/rhachet-brains-anthropic/__test__/getOneDispatchArgs.test.ts` +- [x] create `src/_topublish/rhachet-brains-anthropic/__test__/getOneInteractArgs.test.ts` + +## phase 4: stream parser +- [x] create `src/_topublish/rhachet-brains-anthropic/getOneBrainOutputFromStreamJson.ts` +- [x] create `src/_topublish/rhachet-brains-anthropic/__test__/getOneBrainOutputFromStreamJson.test.ts` + +## phase 5: supplier factory +- [x] create `src/_topublish/rhachet-brains-anthropic/genBrainCli.ts` +- [x] update `src/_topublish/rhachet-brains-anthropic/genBrainCli.ts` — add auth derivation: + - [x] type signature: `context: { cwd: string } & ContextBrainAuth<{ anthropic?: BrainAuthAnthropic }>` + - [x] three-way auth: absent -> oauth default, present+valid -> use, present+invalid -> BadRequestError + - [x] spawnEnv from derived auth (strip ambient keys, inject apiKey if present) + +## phase 6: contract factory +- [x] create `src/_topublish/rhachet/genBrainCli.ts` +- [x] update `src/_topublish/rhachet/index.ts` — export genBrainCli + +## phase 7: supplier index +- [x] update `src/_topublish/rhachet-brains-anthropic/index.ts` +- [x] update `src/_topublish/rhachet-brains-anthropic/index.ts` — add BrainAuthAnthropic, isBrainAuthAnthropic exports + +## phase 8: integration tests +- [x] create integration test suites (7 files): + - [x] genBrainCli.dispatch.ask.integration.test.ts + - [x] genBrainCli.dispatch.act.integration.test.ts + - [x] genBrainCli.dispatch.sequential.sameboot.integration.test.ts + - [x] genBrainCli.dispatch.sequential.reboot.integration.test.ts + - [x] genBrainCli.dispatch.withCompaction.integration.test.ts + - [x] genBrainCli.interact.integration.test.ts + - [x] genBrainCli.guards.integration.test.ts +- [x] create `src/_topublish/rhachet-brains-anthropic/genContextBrainAuthAnthropic.ts` — test helper +- [x] update all 7 integration tests to use shared `CONTEXT` constant with `genContextBrainAuthAnthropic` +- [x] add auth guard integration tests to guards suite: + - [x] valid slug with explicit auth (apiKey) — handle returned + - [x] valid slug with no auth context — defaults to oauth + - [x] valid slug with empty auth context (brain: {}) — defaults to oauth + - [x] valid slug with invalid auth shape — throws BadRequestError + +## phase 9: final verification +- [x] `npm run test:types` — zero errors (all ts-expect-error annotations valid) +- [x] `npm run test:unit` — 71 pass across 7 suites + - 6 slug parse + 8 ContextBrainAuth type + 10 isBrainAuthAnthropic runtime + 11 BrainAuthAnthropic type + 4 dispatch args + 3 interact args + 29 stream parser +- [x] integration tests — 79/80 pass across 7 suites + - ask: 20/20, act: 12/12, sameboot: 7/7, reboot: 7/7, guards: 9/9, compaction: 6/6, interact: 17/18 + - 1 flaky: interact [t1] — pre-extant race in trust prompt auto-accept, not auth-related +- [x] file inventory: + - prod: 13 files (+ 1 updated index.ts) + - test: 9 files (7 integration + 2 typetest) + - total: 22 files diff --git a/.behavior/v2026_02_20.v0p0-bhrain-cli/5.1.execution.phase0_to_phaseN.v1.src b/.behavior/v2026_02_20.v0p0-bhrain-cli/5.1.execution.phase0_to_phaseN.v1.src new file mode 100644 index 0000000..e87a2f2 --- /dev/null +++ b/.behavior/v2026_02_20.v0p0-bhrain-cli/5.1.execution.phase0_to_phaseN.v1.src @@ -0,0 +1,22 @@ +bootup your mechanic's role via `npx rhachet roles boot --repo ehmpathy --role mechanic` + +then, start or continue to execute +- phase0 to phaseN +of roadmap +- .behavior/v2026_02_20.v0p0-bhrain-cli/4.1.roadmap.v1.i1.md + +ref: +- .behavior/v2026_02_20.v0p0-bhrain-cli/0.wish.md +- .behavior/v2026_02_20.v0p0-bhrain-cli/1.vision.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/2.1.criteria.blackbox.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/2.3.criteria.blueprint.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.2.distill.domain._.v1.i1.md (if declared) +- .behavior/v2026_02_20.v0p0-bhrain-cli/3.3.blueprint.v1.i1.md + + +--- + +track your progress + +emit todos and check them off into +- .behavior/v2026_02_20.v0p0-bhrain-cli/5.1.execution.phase0_to_phaseN.v1.i1.md diff --git a/.claude/settings.json b/.claude/settings.json index ea83ac3..a9afd12 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -44,11 +44,10 @@ "Bash(npx rhachet run --skill git.commit.bind:*)", "Bash(npx rhachet run --skill git.commit.bind get)", "Bash(npx rhachet run --skill git.commit.set:*)", - "Bash(npx rhachet run --skill git.commit.set -m 'fix(api): validate input\n\n- added input schema\n- added error handler')", - "Bash(npx rhachet run --skill git.commit.set -m $MESSAGE)", - "Bash(npx rhachet run --skill git.commit.set --mode apply -m $MESSAGE)", - "Bash(npx rhachet run --skill git.commit.set --push -m $MESSAGE)", - "Bash(npx rhachet run --skill git.commit.set --unstaged ignore -m $MESSAGE)", + "Bash(echo $MESSAGE | npx rhachet run --skill git.commit.set -m @stdin)", + "Bash(echo $MESSAGE | npx rhachet run --skill git.commit.set -m @stdin --mode apply)", + "Bash(echo $MESSAGE | npx rhachet run --skill git.commit.set -m @stdin --mode apply --push)", + "Bash(echo $MESSAGE | npx rhachet run --skill git.commit.set -m @stdin --unstaged ignore)", "Bash(npx rhachet run --skill git.commit.push:*)", "Bash(npx rhachet run --skill show.gh.action.logs:*)", "Bash(npx rhachet run --skill show.gh.test.errors:*)", @@ -249,6 +248,12 @@ "command": "./node_modules/.bin/rhachet roles boot --repo ehmpathy --role mechanic", "timeout": 60, "author": "repo=ehmpathy/role=mechanic" + }, + { + "type": "command", + "command": "./node_modules/.bin/rhx route.drive --mode hook", + "timeout": 5, + "author": "repo=bhrain/role=driver" } ] } @@ -320,6 +325,12 @@ "command": "pnpm run --if-present fix", "timeout": 30, "author": "repo=ehmpathy/role=mechanic" + }, + { + "type": "command", + "command": "./node_modules/.bin/rhx route.drive --mode hook", + "timeout": 5, + "author": "repo=bhrain/role=driver" } ] } diff --git a/.github/workflows/.test.yml b/.github/workflows/.test.yml index 21c69dc..e8bd0dc 100644 --- a/.github/workflows/.test.yml +++ b/.github/workflows/.test.yml @@ -12,6 +12,10 @@ on: required: false type: string default: "us-east-1" + secrets: + ANTHROPIC_API_KEY: + description: "api key for anthropic — required for brain CLI integration tests" + required: false permissions: id-token: write # required for oidc @@ -219,12 +223,16 @@ jobs: - name: test:integration (explicit ${{ matrix.shard.index }}) if: matrix.shard.type == 'explicit' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | patterns='${{ join(matrix.shard.patterns, '|') }}' THOROUGH=true npm run test:integration -- --testPathPatterns="$patterns" --json --outputFile=jest-results.json - name: test:integration (dynamic ${{ matrix.shard.shard }}/${{ matrix.shard.total }}) if: matrix.shard.type == 'dynamic' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | exclude='${{ needs.enshard.outputs.integration-patterns }}' if [[ -n "$exclude" ]]; then diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ce5067c..ff105dc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,6 +19,8 @@ jobs: with: creds-aws-region: us-east-1 creds-aws-role-arn: ${{ vars.CREDS_CICD_AWS_DEV_OIDC_ROLE_ARN }} # use aws auth via oidc, if this repo supplies it + secrets: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} publish: uses: ./.github/workflows/.publish-npm.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b09bfb0..c586f4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,3 +22,5 @@ jobs: with: creds-aws-region: us-east-1 creds-aws-role-arn: ${{ vars.CREDS_CICD_AWS_DEV_OIDC_ROLE_ARN }} # use aws auth via oidc, if this repo supplies it + secrets: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/package.json b/package.json index c1f96fd..835c85e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "upgrade:rhachet": "rhachet upgrade" }, "dependencies": { + "@anthropic-ai/claude-code": "2.1.50", "domain-objects": "0.31.9", "helpful-errors": "1.5.3", "type-fns": "1.21.0" @@ -46,6 +47,7 @@ "@biomejs/biome": "2.3.8", "@commitlint/cli": "19.5.0", "@commitlint/config-conventional": "19.5.0", + "@lydell/node-pty": "1.1.0", "@swc/core": "1.15.3", "@swc/jest": "0.2.39", "@tsconfig/node20": "20.1.5", @@ -61,13 +63,12 @@ "esbuild-register": "3.6.0", "husky": "8.0.3", "jest": "30.2.0", - "rhachet": "1.34.0", + "rhachet": "1.35.3", "rhachet-brains-anthropic": "0.3.3", - "rhachet-brains-xai": "0.2.1", - "rhachet-roles-bhrain": "0.11.2", - "rhachet-roles-bhuild": "0.7.0", - "rhachet-roles-ehmpathy": "1.26.0", - "test-fns": "1.7.2", + "rhachet-roles-bhrain": "0.12.5", + "rhachet-roles-bhuild": "0.10.0", + "rhachet-roles-ehmpathy": "1.26.5", + "test-fns": "1.15.4", "tsc-alias": "1.8.10", "tsx": "4.20.6", "typescript": "5.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e2a5cf..68e393e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/claude-code': + specifier: 2.1.50 + version: 2.1.50 domain-objects: specifier: 0.31.9 version: 0.31.9 @@ -27,6 +30,9 @@ importers: '@commitlint/config-conventional': specifier: 19.5.0 version: 19.5.0 + '@lydell/node-pty': + specifier: 1.1.0 + version: 1.1.0 '@swc/core': specifier: 1.15.3 version: 1.15.3 @@ -53,13 +59,13 @@ importers: version: 0.13.15 declapract-typescript-ehmpathy: specifier: 0.47.43 - version: 0.47.43(declapract@0.13.15)(zod@4.3.4) + version: 0.47.43(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(declapract@0.13.15)(zod@4.3.4) declastruct: specifier: 1.7.3 version: 1.7.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(domain-objects@0.31.9)(zod@4.3.4) declastruct-github: specifier: 1.3.0 - version: 1.3.0(zod@4.3.4) + version: 1.3.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) depcheck: specifier: 1.4.3 version: 1.4.3 @@ -73,26 +79,23 @@ importers: specifier: 30.2.0 version: 30.2.0(@types/node@22.15.21)(esbuild-register@3.6.0(esbuild@0.25.12)) rhachet: - specifier: 1.34.0 - version: 1.34.0(zod@4.3.4) + specifier: 1.35.3 + version: 1.35.3(zod@4.3.4) rhachet-brains-anthropic: specifier: 0.3.3 - version: 0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.34.0(zod@4.3.4)) - rhachet-brains-xai: - specifier: 0.2.1 - version: 0.2.1(@types/node@22.15.21)(rhachet@1.34.0(zod@4.3.4)) + version: 0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.35.3(zod@4.3.4)) rhachet-roles-bhrain: - specifier: 0.11.2 - version: 0.11.2(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21) + specifier: 0.12.5 + version: 0.12.5(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21) rhachet-roles-bhuild: - specifier: 0.7.0 - version: 0.7.0 + specifier: 0.10.0 + version: 0.10.0 rhachet-roles-ehmpathy: - specifier: 1.26.0 - version: 1.26.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.34.0(zod@4.3.4)) + specifier: 1.26.5 + version: 1.26.5(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.35.3(zod@4.3.4)) test-fns: - specifier: 1.7.2 - version: 1.7.2 + specifier: 1.15.4 + version: 1.15.4 tsc-alias: specifier: 1.8.10 version: 1.8.10 @@ -114,6 +117,11 @@ packages: peerDependencies: zod: ^3.24.1 || ^4.0.0 + '@anthropic-ai/claude-code@2.1.50': + resolution: {integrity: sha512-urrhY4IRuLHFoEYb6pjRy6sbDE8TH886zwRvIAPS4Tz51MeGVomZet4EajqxX6+IOKbJNbf4IHL3fTzI0vcKXA==} + engines: {node: '>=18.0.0'} + hasBin: true + '@anthropic-ai/sdk@0.51.0': resolution: {integrity: sha512-fAFC/uHhyzfw7rs65EPVV+scXDytGNm5BjttxHf6rP/YGvaBRKEvp2lwyuMigTwMI95neeG4bzrZigz7KCikjw==} hasBin: true @@ -1323,6 +1331,39 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lydell/node-pty-darwin-arm64@1.1.0': + resolution: {integrity: sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==} + cpu: [arm64] + os: [darwin] + + '@lydell/node-pty-darwin-x64@1.1.0': + resolution: {integrity: sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==} + cpu: [x64] + os: [darwin] + + '@lydell/node-pty-linux-arm64@1.1.0': + resolution: {integrity: sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==} + cpu: [arm64] + os: [linux] + + '@lydell/node-pty-linux-x64@1.1.0': + resolution: {integrity: sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==} + cpu: [x64] + os: [linux] + + '@lydell/node-pty-win32-arm64@1.1.0': + resolution: {integrity: sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==} + cpu: [arm64] + os: [win32] + + '@lydell/node-pty-win32-x64@1.1.0': + resolution: {integrity: sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==} + cpu: [x64] + os: [win32] + + '@lydell/node-pty@1.1.0': + resolution: {integrity: sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -4034,24 +4075,24 @@ packages: peerDependencies: rhachet: '>=1.21.4' - rhachet-roles-bhrain@0.11.2: - resolution: {integrity: sha512-xltvF6FPKGcIjTRj+428zQQx2TyusEGTbGGJEFitlFSot3sqDgAPEKm/oj8yYyluiW5ubNVSsavrhGoCgYAHQQ==} + rhachet-roles-bhrain@0.12.5: + resolution: {integrity: sha512-0wq+Qr2rgenpOuuhivPTIFwHD6V7yDSwr0AU0gBZtVFi/roWUf7aTx+S2zg8U0QEvrYcovRW+1Ab6Bx6ec2oRg==} engines: {node: '>=8.0.0'} rhachet-roles-bhrain@0.7.5: resolution: {integrity: sha512-O2zRlITFHmpTHbS3E5PUODlqAWWVt+xV44uu3P3RSLygcgFrG8q9NekLMJTMBsnL2ug4S8mNU7Ji7wCwjkX7qg==} engines: {node: '>=8.0.0'} - rhachet-roles-bhuild@0.7.0: - resolution: {integrity: sha512-OsiuFlTn1ZAqr/FqGcVvPemjk70jS6NHwxDUYbcip0ExkItf+WCjePtAjhQkR0P7h4AAIrxnPRKRW3s/1+CaAQ==} + rhachet-roles-bhuild@0.10.0: + resolution: {integrity: sha512-MMR+ux24hb1BxXNq4zE+ySrjh3H1AqnoUgs51y6oqCx/hQK519FE+LYnYc07H00I5UUTGt7zxhr3HL2DEY2g1g==} engines: {node: '>=18.0.0'} - rhachet-roles-ehmpathy@1.26.0: - resolution: {integrity: sha512-JCKPnfXH7kNw0dUdjx0aMgP6hcnUFEgrsE2sSUSOv4mVbLRKpFJ6hR+L/p8fkDE29kKpcVbZ1IKJrsjASYRAzw==} + rhachet-roles-ehmpathy@1.26.5: + resolution: {integrity: sha512-BvYsSBV5EHhSZ8K6LW6pWWG0ds53wSvTDGJmsGtkUDkbTQ81HgVB6ZHoA4qf4//+Vj5qK3anqUpnGEPyDYsqrA==} engines: {node: '>=8.0.0'} - rhachet@1.34.0: - resolution: {integrity: sha512-3GfHcFECSSMOIOw9dhPFKwoMSn6OFnt8cHyU3w/2/+25AujJVG9mSuffGwGMfv3fE1YZ8Br4ILGVUjnVYpfy/w==} + rhachet@1.35.3: + resolution: {integrity: sha512-vwK0ZjwL+T9zIVWYsnjR4RETURPE2OSCMLhDdsAuHe74+ZNEUD1kAgIQkgLcmG1lPiTvcXDjZH74iSb/ynUSjQ==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: @@ -4275,6 +4316,10 @@ packages: resolution: {integrity: sha512-zC/qUA2lwfiXoQ00Ws8yD8LPA7p3n2a+Dl7wmI4iTXz4HbrU52riPY+AceGWgCAINs/cJ7cs9jnnASSPBwyGpg==} engines: {node: '>=8.0.0'} + test-fns@1.15.4: + resolution: {integrity: sha512-X/38h4K756lVMW1/651XNnXEQPNmPq7pPTxLxXmx7lOQiaDZR8uY/zOkclhSPFGyTA/MeqQYaUuKqcvTX4PP7Q==} + engines: {node: '>=8.0.0'} + test-fns@1.4.2: resolution: {integrity: sha512-Qz46tRQ7XjiCB5uZM+jLmluZBcp+dKTQ7wisoz8IJtLVUZN+Ta8DWksmTVS/pcdXieKR01gjuukDZHhIDcZvog==} engines: {node: '>=8.0.0'} @@ -4596,6 +4641,18 @@ snapshots: '@img/sharp-linuxmusl-x64': 0.33.5 '@img/sharp-win32-x64': 0.33.5 + '@anthropic-ai/claude-code@2.1.50': + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + '@anthropic-ai/sdk@0.51.0': {} '@anthropic-ai/sdk@0.71.2(zod@4.3.4)': @@ -6176,6 +6233,33 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lydell/node-pty-darwin-arm64@1.1.0': + optional: true + + '@lydell/node-pty-darwin-x64@1.1.0': + optional: true + + '@lydell/node-pty-linux-arm64@1.1.0': + optional: true + + '@lydell/node-pty-linux-x64@1.1.0': + optional: true + + '@lydell/node-pty-win32-arm64@1.1.0': + optional: true + + '@lydell/node-pty-win32-x64@1.1.0': + optional: true + + '@lydell/node-pty@1.1.0': + optionalDependencies: + '@lydell/node-pty-darwin-arm64': 1.1.0 + '@lydell/node-pty-darwin-x64': 1.1.0 + '@lydell/node-pty-linux-arm64': 1.1.0 + '@lydell/node-pty-linux-x64': 1.1.0 + '@lydell/node-pty-win32-arm64': 1.1.0 + '@lydell/node-pty-win32-x64': 1.1.0 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -7555,16 +7639,21 @@ snapshots: dependencies: ms: 2.1.3 - declapract-typescript-ehmpathy@0.47.43(declapract@0.13.15)(zod@4.3.4): + declapract-typescript-ehmpathy@0.47.43(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(declapract@0.13.15)(zod@4.3.4): dependencies: declapract: 0.13.15 domain-objects: 0.31.9 expect: 29.4.2 flat: 5.0.2 helpful-errors: 1.5.3 - test-fns: 1.15.0(zod@4.3.4) + test-fns: 1.15.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) yaml: 2.8.2 transitivePeerDependencies: + - '@huggingface/transformers' + - '@tensorflow/tfjs' + - '@types/node' + - aws-crt + - ws - zod declapract@0.13.15: @@ -7590,7 +7679,7 @@ snapshots: uuid: 9.0.0 yaml: 1.6.0 - declastruct-github@1.3.0(zod@4.3.4): + declastruct-github@1.3.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): dependencies: '@ehmpathy/uni-time': 1.7.4 '@octokit/rest': 21.1.1 @@ -7598,12 +7687,16 @@ snapshots: domain-objects: 0.31.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) helpful-errors: 1.5.3 libsodium-wrappers: 0.7.16 - simple-in-memory-cache: 0.4.0(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) type-fns: 1.21.0 visualogic: 1.3.2 - with-simple-cache: 0.15.1(zod@4.3.4) + with-simple-cache: 0.15.1(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) transitivePeerDependencies: + - '@huggingface/transformers' + - '@tensorflow/tfjs' + - '@types/node' - aws-crt + - ws - zod declastruct@1.7.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(domain-objects@0.31.9)(zod@4.3.4): @@ -7615,7 +7708,7 @@ snapshots: helpful-errors: 1.5.3 jest-diff: 30.0.2 rhachet-artifact-git: 1.1.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) - simple-log-methods: 0.6.2(zod@4.3.4) + simple-log-methods: 0.6.2(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) tsx: 4.20.6 type-fns: 1.21.0 uuid-fns: 1.0.2 @@ -7734,8 +7827,8 @@ snapshots: domain-objects: 0.31.3 helpful-errors: 1.5.3 joi: 17.4.0 - rhachet: 1.34.0(zod@4.3.4) - rhachet-roles-ehmpathy: 1.26.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.34.0(zod@4.3.4)) + rhachet: 1.35.3(zod@4.3.4) + rhachet-roles-ehmpathy: 1.26.5(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.35.3(zod@4.3.4)) type-fns: 1.21.0 uuid-fns: 1.1.3 transitivePeerDependencies: @@ -9344,7 +9437,7 @@ snapshots: domain-objects: 0.31.9 helpful-errors: 1.5.3 - rhachet-brains-anthropic@0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.34.0(zod@4.3.4)): + rhachet-brains-anthropic@0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.35.3(zod@4.3.4)): dependencies: '@anthropic-ai/claude-agent-sdk': 0.1.76(zod@4.3.4) '@anthropic-ai/sdk': 0.71.2(zod@4.3.4) @@ -9352,7 +9445,7 @@ snapshots: helpful-errors: 1.5.3 iso-price: 1.1.1(domain-objects@0.31.9) iso-time: 1.11.1 - rhachet: 1.34.0(zod@4.3.4) + rhachet: 1.35.3(zod@4.3.4) rhachet-artifact: 1.0.1(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) rhachet-artifact-git: 1.1.5 type-fns: 1.21.0 @@ -9364,13 +9457,13 @@ snapshots: - aws-crt - ws - rhachet-brains-xai@0.2.1(@types/node@22.15.21)(rhachet@1.34.0(zod@4.3.4)): + rhachet-brains-xai@0.2.1(@types/node@22.15.21)(rhachet@1.35.3(zod@4.3.4)): dependencies: domain-objects: 0.31.9 helpful-errors: 1.5.3 iso-price: 1.1.1(domain-objects@0.31.9) openai: 5.8.2(zod@4.3.4) - rhachet: 1.34.0(zod@4.3.4) + rhachet: 1.35.3(zod@4.3.4) rhachet-artifact: 1.0.1(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) rhachet-artifact-git: 1.1.5 rhachet-roles-bhrain: 0.7.5(@types/node@22.15.21) @@ -9381,7 +9474,7 @@ snapshots: - aws-crt - ws - rhachet-roles-bhrain@0.11.2(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21): + rhachet-roles-bhrain@0.12.5(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21): dependencies: '@anthropic-ai/sdk': 0.51.0 '@ehmpathy/as-command': 1.0.3 @@ -9398,9 +9491,9 @@ snapshots: rhachet-artifact: 1.0.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) rhachet-artifact-git: 1.1.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) serde-fns: 1.2.0 - simple-in-memory-cache: 0.4.0(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) type-fns: 1.21.0 - with-simple-caching: 0.14.2(zod@4.3.4) + with-simple-caching: 0.14.2 wrapper-fns: 1.1.0 zod: 4.3.4 transitivePeerDependencies: @@ -9427,9 +9520,9 @@ snapshots: rhachet-artifact: 1.0.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) rhachet-artifact-git: 1.1.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) serde-fns: 1.2.0 - simple-in-memory-cache: 0.4.0(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) type-fns: 1.21.0 - with-simple-caching: 0.14.2(zod@4.3.4) + with-simple-caching: 0.14.2 wrapper-fns: 1.1.0 zod: 4.3.4 transitivePeerDependencies: @@ -9437,15 +9530,16 @@ snapshots: - aws-crt - ws - rhachet-roles-bhuild@0.7.0: + rhachet-roles-bhuild@0.10.0: dependencies: domain-objects: 0.31.9 emoji-space-shim: 0.0.0 helpful-errors: 1.5.3 + iso-time: 1.11.3 test-fns: 1.7.2 zod: 4.3.4 - rhachet-roles-ehmpathy@1.26.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.34.0(zod@4.3.4)): + rhachet-roles-ehmpathy@1.26.5(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet@1.35.3(zod@4.3.4)): dependencies: '@atjsh/llmlingua-2': 2.0.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(js-tiktoken@1.0.21) '@ehmpathy/as-command': 1.0.3 @@ -9459,13 +9553,13 @@ snapshots: openai: 5.8.2(zod@4.3.4) rhachet-artifact: 1.0.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) rhachet-artifact-git: 1.1.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) - rhachet-brains-xai: 0.2.1(@types/node@22.15.21)(rhachet@1.34.0(zod@4.3.4)) + rhachet-brains-xai: 0.2.1(@types/node@22.15.21)(rhachet@1.35.3(zod@4.3.4)) serde-fns: 1.2.0 - simple-in-memory-cache: 0.4.0(zod@4.3.4) - simple-on-disk-cache: 1.7.3(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + simple-on-disk-cache: 1.7.3 type-fns: 1.21.0 with-simple-cache: 0.15.3(zod@4.3.4) - with-simple-caching: 0.14.4(zod@4.3.4) + with-simple-caching: 0.14.4 wrapper-fns: 1.1.7 zod: 4.3.4 transitivePeerDependencies: @@ -9476,9 +9570,12 @@ snapshots: - rhachet - ws - rhachet@1.34.0(zod@4.3.4): + rhachet@1.35.3(zod@4.3.4): dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 '@octokit/auth-app': 8.2.0 + '@scure/base': 2.0.0 age-encryption: 0.3.0 as-procedure: 1.1.11 bottleneck: 2.19.5 @@ -9622,10 +9719,15 @@ snapshots: signal-exit@4.1.0: {} - simple-in-memory-cache@0.4.0(zod@4.3.4): + simple-in-memory-cache@0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): dependencies: '@ehmpathy/uni-time': 1.9.4(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) transitivePeerDependencies: + - '@huggingface/transformers' + - '@tensorflow/tfjs' + - '@types/node' + - aws-crt + - ws - zod simple-log-methods@0.6.1(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): @@ -9642,13 +9744,18 @@ snapshots: - ws - zod - simple-log-methods@0.6.2(zod@4.3.4): + simple-log-methods@0.6.2(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): dependencies: '@ehmpathy/error-fns': 1.3.7 '@ehmpathy/uni-time': 1.9.4(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) domain-glossary-procedure: 1.0.0 type-fns: 1.21.0 transitivePeerDependencies: + - '@huggingface/transformers' + - '@tensorflow/tfjs' + - '@types/node' + - aws-crt + - ws - zod simple-log-methods@0.6.9: @@ -9657,10 +9764,10 @@ snapshots: domain-glossary-procedure: 1.0.0 domain-objects: 0.31.9 helpful-errors: 1.5.3 - iso-time: 1.11.1 + iso-time: 1.11.3 type-fns: 1.21.0 - simple-on-disk-cache@1.7.3(zod@4.3.4): + simple-on-disk-cache@1.7.3: dependencies: '@aws-sdk/client-s3': 3.943.0 '@ehmpathy/uni-time': 1.9.4(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) @@ -9669,11 +9776,10 @@ snapshots: hash-fns: 1.1.0 helpful-errors: 1.5.3 serde-fns: 1.3.1 - simple-in-memory-cache: 0.4.0(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) type-fns: 1.21.0 transitivePeerDependencies: - aws-crt - - zod slash@3.0.0: {} @@ -9770,15 +9876,27 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 - test-fns@1.15.0(zod@4.3.4): + test-fns@1.15.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): dependencies: domain-objects: 0.31.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) helpful-errors: 1.5.3 iso-time: 1.11.3 uuid: 10.0.0 transitivePeerDependencies: + - '@huggingface/transformers' + - '@tensorflow/tfjs' + - '@types/node' + - aws-crt + - ws - zod + test-fns@1.15.4: + dependencies: + domain-objects: 0.31.9 + helpful-errors: 1.5.3 + iso-time: 1.11.3 + uuid: 10.0.0 + test-fns@1.4.2: dependencies: '@ehmpathy/error-fns': 1.3.1 @@ -9988,16 +10106,20 @@ snapshots: dependencies: isexe: 2.0.0 - with-simple-cache@0.15.1(zod@4.3.4): + with-simple-cache@0.15.1(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4): dependencies: '@ehmpathy/uni-time': 1.9.4(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) procedure-fns: 1.0.1 serde-fns: 1.3.1 - simple-in-memory-cache: 0.4.0(zod@4.3.4) - simple-on-disk-cache: 1.7.3(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + simple-on-disk-cache: 1.7.3 type-fns: 1.21.0 transitivePeerDependencies: + - '@huggingface/transformers' + - '@tensorflow/tfjs' + - '@types/node' - aws-crt + - ws - zod with-simple-cache@0.15.3(zod@4.3.4): @@ -10007,36 +10129,34 @@ snapshots: helpful-errors: 1.5.3 procedure-fns: 1.0.1 serde-fns: 1.3.1 - simple-in-memory-cache: 0.4.0(zod@4.3.4) - simple-on-disk-cache: 1.7.3(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + simple-on-disk-cache: 1.7.3 type-fns: 1.21.0 transitivePeerDependencies: - aws-crt - zod - with-simple-caching@0.14.2(zod@4.3.4): + with-simple-caching@0.14.2: dependencies: '@ehmpathy/uni-time': 1.9.4(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) serde-fns: 1.3.1 - simple-in-memory-cache: 0.4.0(zod@4.3.4) - simple-on-disk-cache: 1.7.3(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + simple-on-disk-cache: 1.7.3 type-fns: 1.21.0 visualogic: 1.3.3 transitivePeerDependencies: - aws-crt - - zod - with-simple-caching@0.14.4(zod@4.3.4): + with-simple-caching@0.14.4: dependencies: '@ehmpathy/uni-time': 1.9.4(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) procedure-fns: 1.0.1 serde-fns: 1.3.1 - simple-in-memory-cache: 0.4.0(zod@4.3.4) - simple-on-disk-cache: 1.7.3(zod@4.3.4) + simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(zod@4.3.4) + simple-on-disk-cache: 1.7.3 type-fns: 1.21.0 transitivePeerDependencies: - aws-crt - - zod word-wrap@1.2.5: {} diff --git a/src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.ts b/src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.ts new file mode 100644 index 0000000..90208b3 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.ts @@ -0,0 +1,26 @@ +import type { PickOne } from 'type-fns'; + +/** + * .what = auth shape for the anthropic brain CLI supplier + * .why = explicit auth — callers declare api key or oauth intent + */ +export type BrainAuthAnthropic = { + via: PickOne<{ + oauth: true; + apiKey: string; + }>; +}; + +/** + * .what = type guard for BrainAuthAnthropic + * .why = suppliers validate auth at runtime — no unsafe `as` cast + */ +export const isBrainAuthAnthropic = ( + input: unknown, +): input is BrainAuthAnthropic => { + if (!input || typeof input !== 'object') return false; + const candidate = input as Record; + if (!candidate.via || typeof candidate.via !== 'object') return false; + const via = candidate.via as Record; + return via.oauth === true || typeof via.apiKey === 'string'; +}; diff --git a/src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.type.test.ts b/src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.type.test.ts new file mode 100644 index 0000000..782dfef --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/BrainAuthAnthropic.type.test.ts @@ -0,0 +1,98 @@ +import { given, then, when } from 'test-fns'; + +import type { BrainAuthAnthropic } from './BrainAuthAnthropic'; +import { genContextBrainAuthAnthropic } from './genContextBrainAuthAnthropic'; + +/** + * .what = compile-time type assertions for BrainAuthAnthropic and genContextBrainAuthAnthropic + * .why = proves PickOne enforces exactly one auth variant at compile time + */ +describe('BrainAuthAnthropic', () => { + given('[case1] valid via shapes', () => { + when('[t0] positive: oauth via', () => { + then('it compiles', () => { + const auth: BrainAuthAnthropic = { via: { oauth: true } }; + expect(auth).toBeDefined(); + }); + }); + + when('[t1] positive: apiKey via', () => { + then('it compiles', () => { + const auth: BrainAuthAnthropic = { via: { apiKey: 'sk-test' } }; + expect(auth).toBeDefined(); + }); + }); + }); + + given('[case2] invalid via shapes', () => { + when('[t0] negative: empty via', () => { + then('it fails to compile', () => { + // @ts-expect-error — via must have exactly one key (oauth or apiKey) + const _auth: BrainAuthAnthropic = { via: {} }; + }); + }); + + when('[t1] negative: oauth with wrong value type', () => { + then('it fails to compile', () => { + // @ts-expect-error — oauth must be true, not a string + const _auth: BrainAuthAnthropic = { via: { oauth: 'yes' } }; + }); + }); + + when('[t2] negative: apiKey with wrong value type', () => { + then('it fails to compile', () => { + // @ts-expect-error — apiKey must be string, not number + const _auth: BrainAuthAnthropic = { via: { apiKey: 123 } }; + }); + }); + + when('[t3] negative: unrecognized via key', () => { + then('it fails to compile', () => { + // @ts-expect-error — 'password' is not a valid via key + const _auth: BrainAuthAnthropic = { via: { password: 'secret' } }; + }); + }); + + when('[t4] negative: absent via key', () => { + then('it fails to compile', () => { + // @ts-expect-error — via is required + const _auth: BrainAuthAnthropic = {}; + }); + }); + }); + + given('[case3] genContextBrainAuthAnthropic type safety', () => { + when('[t0] positive: valid apiKey via', () => { + then('it compiles and returns correct shape', () => { + const ctx = genContextBrainAuthAnthropic({ + via: { apiKey: 'sk-test' }, + }); + expect(ctx.brain).toBeDefined(); + expect(ctx.brain!.auth).toBeDefined(); + }); + }); + + when('[t1] positive: valid oauth via', () => { + then('it compiles', () => { + const ctx = genContextBrainAuthAnthropic({ + via: { oauth: true }, + }); + expect(ctx).toBeDefined(); + }); + }); + + when('[t2] negative: empty via', () => { + then('it fails to compile', () => { + // @ts-expect-error — via must have exactly one key + genContextBrainAuthAnthropic({ via: {} }); + }); + }); + + when('[t3] negative: wrong via key', () => { + then('it fails to compile', () => { + // @ts-expect-error — 'token' is not a valid via key + genContextBrainAuthAnthropic({ via: { token: 'abc' } }); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/BrainCli.config.ts b/src/_topublish/rhachet-brains-anthropic/BrainCli.config.ts new file mode 100644 index 0000000..7078f08 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/BrainCli.config.ts @@ -0,0 +1,101 @@ +import { BadRequestError } from 'helpful-errors'; +import type { BrainSpec } from 'rhachet'; +import { + type AnthropicBrainAtomSlug, + CONFIG_BY_ATOM_SLUG, +} from 'rhachet-brains-anthropic/dist/domain.operations/atoms/BrainAtom.config'; + +/** + * .what = supported anthropic brain CLI slugs + * .why = type-safe slug specification for BrainCli handles + * + * .note = format: '@/' + * .note = the CLI replaces the repl — it supplies its own tool-use loop — so the slug references the atom (model) directly + */ +export type AnthropicBrainCliSlug = + | 'claude@anthropic/claude/haiku' + | 'claude@anthropic/claude/haiku/v4.5' + | 'claude@anthropic/claude/sonnet' + | 'claude@anthropic/claude/sonnet/v4' + | 'claude@anthropic/claude/sonnet/v4.5' + | 'claude@anthropic/claude/opus' + | 'claude@anthropic/claude/opus/v4.5'; + +/** + * .what = config shape for a brain CLI supplier + * .why = maps a slug to the model, spec, and tool sets needed for spawn + */ +export interface AnthropicBrainCliConfig { + slug: AnthropicBrainCliSlug; + model: string; + spec: BrainSpec; + tools: { + ask: string[]; + act: string[]; + }; +} + +// shared tool sets +const TOOLS_ASK = ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'] as const; +const TOOLS_ACT = [ + 'Read', + 'Grep', + 'Glob', + 'Edit', + 'Write', + 'Bash', + 'WebSearch', + 'WebFetch', +] as const; + +/** + * .what = extract the atom slug from a CLI slug + * .why = the atom slug portion after '@/' maps to CONFIG_BY_ATOM_SLUG + */ +const getOneAtomSlug = (input: { + cliSlug: AnthropicBrainCliSlug; +}): AnthropicBrainAtomSlug => { + // cli slug format: '@/' + const afterAt = input.cliSlug.slice(input.cliSlug.indexOf('@') + 1); + const atomSlug = afterAt.slice(afterAt.indexOf('/') + 1); + return atomSlug as AnthropicBrainAtomSlug; +}; + +/** + * .what = build a BrainCli config from a CLI slug + * .why = explicit config derivation — called directly, no hidden map + */ +export const getOneAnthropicBrainCliConfig = (input: { + slug: string; +}): AnthropicBrainCliConfig => { + // validate slug format + const validSlugs: AnthropicBrainCliSlug[] = [ + 'claude@anthropic/claude/haiku', + 'claude@anthropic/claude/haiku/v4.5', + 'claude@anthropic/claude/sonnet', + 'claude@anthropic/claude/sonnet/v4', + 'claude@anthropic/claude/sonnet/v4.5', + 'claude@anthropic/claude/opus', + 'claude@anthropic/claude/opus/v4.5', + ]; + if (!validSlugs.includes(input.slug as AnthropicBrainCliSlug)) + BadRequestError.throw('unrecognized anthropic brain CLI slug', { + slug: input.slug, + valid: validSlugs, + }); + + // derive atom config from slug + const cliSlug = input.slug as AnthropicBrainCliSlug; + const atomSlug = getOneAtomSlug({ cliSlug }); + const atomConfig = CONFIG_BY_ATOM_SLUG[atomSlug]; + + return { + slug: cliSlug, + model: atomConfig.model, + spec: atomConfig.spec, + tools: { + ask: [...TOOLS_ASK], + act: [...TOOLS_ACT], + }, + }; +}; diff --git a/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.act.integration.test.ts b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.act.integration.test.ts new file mode 100644 index 0000000..361b6d8 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.act.integration.test.ts @@ -0,0 +1,155 @@ +import { UnexpectedCodePathError } from 'helpful-errors'; +import { genTempDir, given, then, useBeforeAll, useThen, when } from 'test-fns'; + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { genBrainCli } from '../rhachet/genBrainCli'; +import { genContextBrainAuthAnthropic } from './genContextBrainAuthAnthropic'; + +const SLUG_HAIKU = 'claude@anthropic/claude/haiku'; + +describe('genBrainCli.dispatch.act', () => { + // use a temp dir as cwd — avoids repo hooks and provides a clean writable directory + const scene = useBeforeAll(async () => { + const cwd = genTempDir({ slug: 'braincli-act' }); + return { + cwd, + context: { + cwd, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw( + 'ANTHROPIC_API_KEY must be set via use.apikeys.sh', + ), + }, + }), + }, + }; + }); + + given('[case1] act on a booted dispatch handle', () => { + when('[t0] act is called with a cheap prompt', () => { + const result = useThen('act succeeds', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot dispatch mode + await brain.executor.boot({ mode: 'dispatch' }); + + // act with a cheap prompt + const output = await brain.act({ + prompt: 'respond with just the word ok', + }); + + // cleanup + brain.executor.kill(); + + return { output }; + }); + + then('BrainOutput has non-empty text', () => { + expect(result.output.output).toBeDefined(); + expect(result.output.output.length).toBeGreaterThan(0); + }); + + then('BrainOutput.metrics.size.tokens.input > 0', () => { + expect(result.output.metrics.size.tokens.input).toBeGreaterThan(0); + }); + + then('BrainOutput.metrics.size.tokens.output > 0', () => { + expect(result.output.metrics.size.tokens.output).toBeGreaterThan(0); + }); + + then('BrainOutput.episode is defined', () => { + expect(result.output.episode).toBeDefined(); + expect(result.output.episode.hash).toBeDefined(); + }); + + then('BrainOutput.series is defined', () => { + expect(result.output.series).toBeDefined(); + expect(result.output.series!.hash).toBeDefined(); + }); + }); + }); + + given( + '[case2] act mode permits mutation tools while ask mode restricts them', + () => { + when('[t0] act is asked to write a file', () => { + const result = useThen('act writes the file', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot dispatch mode + await brain.executor.boot({ mode: 'dispatch' }); + + // act: ask the brain to write a file — act mode has Write tool + const targetFile = join(scene.cwd, 'act-proof.txt'); + const output = await brain.act({ + prompt: `write a file at the absolute path ${targetFile} with the content "act-was-here". use the Write tool. do not respond with any other text besides a confirmation that you wrote the file.`, + }); + + // check if the file was written + const fileExists = existsSync(targetFile); + + // cleanup + brain.executor.kill(); + + return { output, fileExists, targetFile }; + }); + + then('the file was written by the brain', () => { + expect(result.fileExists).toEqual(true); + }); + + then('BrainOutput is non-empty', () => { + expect(result.output.output.length).toBeGreaterThan(0); + }); + }); + + when('[t1] ask is asked to write a file', () => { + const result = useThen( + 'ask cannot write the file (tools restricted)', + async () => { + const brain = await genBrainCli( + { slug: SLUG_HAIKU }, + scene.context, + ); + + // boot dispatch mode + await brain.executor.boot({ mode: 'dispatch' }); + + // ask: ask the brain to write a file — ask mode does NOT have Write tool + const targetFile = join(scene.cwd, 'ask-proof.txt'); + const output = await brain.ask({ + prompt: `write a file at the absolute path ${targetFile} with the content "ask-was-here". use the Write tool. do not respond with any other text besides a confirmation that you wrote the file.`, + }); + + // check if the file was written (it should NOT be) + const fileExists = existsSync(targetFile); + + // cleanup + brain.executor.kill(); + + return { output, fileExists, targetFile }; + }, + ); + + then( + 'the file was NOT written by the brain (ask mode has no Write tool)', + () => { + expect(result.fileExists).toEqual(false); + }, + ); + + then( + 'BrainOutput is defined (brain completed the task, even without Write tool)', + () => { + expect(result.output).toBeDefined(); + expect(result.output.output).toBeDefined(); + }, + ); + }); + }, + ); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.ask.integration.test.ts b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.ask.integration.test.ts new file mode 100644 index 0000000..8ff44a1 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.ask.integration.test.ts @@ -0,0 +1,224 @@ +import { UnexpectedCodePathError } from 'helpful-errors'; +import { genTempDir, given, then, useBeforeAll, useThen, when } from 'test-fns'; + +import { genBrainCli } from '../rhachet/genBrainCli'; +import { genContextBrainAuthAnthropic } from './genContextBrainAuthAnthropic'; + +const SLUG_HAIKU = 'claude@anthropic/claude/haiku'; + +describe('genBrainCli.dispatch.ask', () => { + const scene = useBeforeAll(async () => { + const cwd = genTempDir({ slug: 'braincli-ask' }); + return { + cwd, + context: { + cwd, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw( + 'ANTHROPIC_API_KEY must be set via use.apikeys.sh', + ), + }, + }), + }, + }; + }); + + given('[case1] a valid haiku brain slug', () => { + when('[t0] genBrainCli is called', () => { + const brain = useThen('it returns a BrainCli handle', async () => { + const result = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + expect(result).toBeDefined(); + expect(result.ask).toBeDefined(); + expect(result.act).toBeDefined(); + expect(result.executor).toBeDefined(); + expect(result.terminal).toBeDefined(); + return result; + }); + + then('brain.executor.instance is null before boot', () => { + expect(brain.executor.instance).toBeNull(); + }); + + then('brain.memory.series is null before boot', () => { + expect(brain.memory.series).toBeNull(); + }); + }); + + when('[t1] boot dispatch and ask a cheap question', () => { + const result = useThen('ask succeeds', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot dispatch mode + await brain.executor.boot({ mode: 'dispatch' }); + expect(brain.executor.instance).not.toBeNull(); + expect(brain.executor.instance!.mode).toEqual('dispatch'); + expect(brain.executor.instance!.pid).toBeGreaterThan(0); + + // ask a cheap question + const output = await brain.ask({ + prompt: 'respond with just the word ok', + }); + + // capture state before kill + const seriesExid = brain.memory.series?.exid ?? null; + const pid = brain.executor.instance?.pid ?? null; + + // cleanup + brain.executor.kill(); + + return { output, seriesExid, pid }; + }); + + then('BrainOutput has non-empty text', () => { + expect(result.output.output).toBeDefined(); + expect(result.output.output.length).toBeGreaterThan(0); + }); + + then('BrainOutput.metrics.size.tokens.input > 0', () => { + expect(result.output.metrics.size.tokens.input).toBeGreaterThan(0); + }); + + then('BrainOutput.metrics.size.tokens.output > 0', () => { + expect(result.output.metrics.size.tokens.output).toBeGreaterThan(0); + }); + + then('BrainOutput.episode is defined', () => { + expect(result.output.episode).toBeDefined(); + expect(result.output.episode.hash).toBeDefined(); + }); + + then('BrainOutput.series is defined', () => { + expect(result.output.series).toBeDefined(); + expect(result.output.series!.hash).toBeDefined(); + }); + + then('memory.series is populated after ask', () => { + expect(result.seriesExid).not.toBeNull(); + }); + }); + + when('[t2] terminal.onData fires with output chunks', () => { + const result = useThen('data callback fires', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // register data callback before boot + const chunks: string[] = []; + brain.terminal.onData((chunk) => chunks.push(chunk)); + + // boot and ask + await brain.executor.boot({ mode: 'dispatch' }); + await brain.ask({ prompt: 'respond with just the word ok' }); + + // cleanup + brain.executor.kill(); + + return { chunks }; + }); + + then('at least one chunk was received', () => { + expect(result.chunks.length).toBeGreaterThan(0); + }); + }); + + when('[t3] terminal.onExit fires on kill', () => { + const result = useThen('exit callback fires', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // register exit callback with fire counter + let fireCount = 0; + let exitInfo: { code: number; signal: string | null } | null = null; + brain.terminal.onExit((info) => { + fireCount += 1; + exitInfo = info; + }); + + // boot + await brain.executor.boot({ mode: 'dispatch' }); + expect(brain.executor.instance).not.toBeNull(); + + // kill + brain.executor.kill(); + + // wait for exit event to propagate + await new Promise((r) => setTimeout(r, 500)); + + return { exitInfo, fireCount }; + }); + + then('exit callback received exit info', () => { + expect(result.exitInfo).not.toBeNull(); + const info = result.exitInfo as unknown as { + code: number; + signal: string | null; + }; + expect(info.code).toBeDefined(); + }); + + then('exit callback fired exactly once', () => { + expect(result.fireCount).toEqual(1); + }); + }); + + when('[t4] terminal.write sends raw data to dispatch stdin', () => { + const result = useThen('write succeeds without error', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot dispatch mode + await brain.executor.boot({ mode: 'dispatch' }); + + // write raw nd-JSON to stdin via terminal.write (same as ask does internally) + const message = JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'respond with just the word ok' }, + }); + brain.terminal.write(message + '\n'); + + // verify the process is still alive after write + const instanceAfterWrite = brain.executor.instance; + + // cleanup + brain.executor.kill(); + + return { instanceAfterWrite }; + }); + + then('process is still alive after write', () => { + expect(result.instanceAfterWrite).not.toBeNull(); + expect(result.instanceAfterWrite!.mode).toEqual('dispatch'); + }); + }); + + when('[t5] series is preserved across reboot', () => { + const result = useThen('reboot preserves series', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot and ask to populate series + await brain.executor.boot({ mode: 'dispatch' }); + await brain.ask({ prompt: 'respond with just the word ok' }); + const seriesBefore = brain.memory.series; + + // reboot + await brain.executor.boot({ mode: 'dispatch' }); + const seriesAfter = brain.memory.series; + const instanceAfter = brain.executor.instance; + + // cleanup + brain.executor.kill(); + + return { seriesBefore, seriesAfter, instanceAfter }; + }); + + then('series exid is preserved', () => { + expect(result.seriesAfter?.exid).toEqual(result.seriesBefore?.exid); + }); + + then('instance has a new pid', () => { + expect(result.instanceAfter).not.toBeNull(); + expect(result.instanceAfter!.mode).toEqual('dispatch'); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.sequential.reboot.integration.test.ts b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.sequential.reboot.integration.test.ts new file mode 100644 index 0000000..7c8cbc8 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.sequential.reboot.integration.test.ts @@ -0,0 +1,107 @@ +import { UnexpectedCodePathError } from 'helpful-errors'; +import { genTempDir, given, then, useBeforeAll, useThen, when } from 'test-fns'; + +import { genBrainCli } from '../rhachet/genBrainCli'; +import { genContextBrainAuthAnthropic } from './genContextBrainAuthAnthropic'; + +const SLUG_HAIKU = 'claude@anthropic/claude/haiku'; + +describe('genBrainCli.dispatch.sequential.reboot', () => { + const scene = useBeforeAll(async () => { + const cwd = genTempDir({ slug: 'braincli-reboot' }); + return { + cwd, + context: { + cwd, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw( + 'ANTHROPIC_API_KEY must be set via use.apikeys.sh', + ), + }, + }), + }, + }; + }); + + given('[case1] sequential asks across reboots (with --resume)', () => { + when('[t0] two asks are dispatched', () => { + const result = useThen('both asks succeed', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot and first ask + await brain.executor.boot({ mode: 'dispatch' }); + const outputFirst = await brain.ask({ + prompt: 'respond with just the word ok', + }); + + // reboot and second ask (process may exit after each dispatch completion) + await brain.executor.boot({ mode: 'dispatch' }); + const outputSecond = await brain.ask({ + prompt: 'respond with just the word yes', + }); + + // capture series after both asks + const seriesAfter = brain.memory.series; + + // cleanup + brain.executor.kill(); + + return { outputFirst, outputSecond, seriesAfter }; + }); + + then('first BrainOutput has non-zero tokens', () => { + expect(result.outputFirst.metrics.size.tokens.input).toBeGreaterThan(0); + expect(result.outputFirst.metrics.size.tokens.output).toBeGreaterThan( + 0, + ); + }); + + then('second BrainOutput has non-empty output text', () => { + expect(result.outputSecond.output).toBeDefined(); + expect(result.outputSecond.output.length).toBeGreaterThan(0); + // note: token counts may be 0 on --resume (CLI does not report them on resumed sessions) + }); + + then('each BrainOutput has independent output', () => { + // both should have non-empty output text — proves independent results + expect(result.outputFirst.output.length).toBeGreaterThan(0); + expect(result.outputSecond.output.length).toBeGreaterThan(0); + + // metrics are independent (not cumulative) — both have their own size.chars + expect(result.outputFirst.metrics.size.chars.output).toBeGreaterThan(0); + expect(result.outputSecond.metrics.size.chars.output).toBeGreaterThan( + 0, + ); + }); + + then( + 'series has exactly 1 episode (same context window via --resume)', + () => { + expect(result.seriesAfter).not.toBeNull(); + expect(result.seriesAfter!.episodes.length).toEqual(1); + }, + ); + + then('that episode has 2 exchanges (one per ask)', () => { + const episode = result.seriesAfter!.episodes[0]!; + expect(episode.exchanges.length).toEqual(2); + }); + + then('each exchange has its own input and output', () => { + const episode = result.seriesAfter!.episodes[0]!; + const [first, second] = episode.exchanges; + + // first exchange + expect(first!.input).toContain('ok'); + expect(first!.output.length).toBeGreaterThan(0); + + // second exchange + expect(second!.input).toContain('yes'); + expect(second!.output.length).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.sequential.sameboot.integration.test.ts b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.sequential.sameboot.integration.test.ts new file mode 100644 index 0000000..604c37a --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.sequential.sameboot.integration.test.ts @@ -0,0 +1,107 @@ +import { UnexpectedCodePathError } from 'helpful-errors'; +import { genTempDir, given, then, useBeforeAll, useThen, when } from 'test-fns'; + +import { genBrainCli } from '../rhachet/genBrainCli'; +import { genContextBrainAuthAnthropic } from './genContextBrainAuthAnthropic'; + +const SLUG_HAIKU = 'claude@anthropic/claude/haiku'; + +describe('genBrainCli.dispatch.sequential.sameboot', () => { + const scene = useBeforeAll(async () => { + const cwd = genTempDir({ slug: 'braincli-sameboot' }); + return { + cwd, + context: { + cwd, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw( + 'ANTHROPIC_API_KEY must be set via use.apikeys.sh', + ), + }, + }), + }, + }; + }); + + given('[case1] two asks on the same boot (no reboot)', () => { + when('[t0] two asks are dispatched on the same process', () => { + const result = useThen('both asks succeed', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot once + await brain.executor.boot({ mode: 'dispatch' }); + + // first ask + const outputFirst = await brain.ask({ + prompt: 'respond with just the word ok', + }); + + // second ask on the same boot — no reboot + const outputSecond = await brain.ask({ + prompt: 'respond with just the word yes', + }); + + // capture series after both asks + const seriesAfter = brain.memory.series; + + // cleanup + brain.executor.kill(); + + return { outputFirst, outputSecond, seriesAfter }; + }); + + then('first BrainOutput has non-zero tokens', () => { + expect(result.outputFirst.metrics.size.tokens.input).toBeGreaterThan(0); + expect(result.outputFirst.metrics.size.tokens.output).toBeGreaterThan( + 0, + ); + }); + + then('second BrainOutput has non-empty output text', () => { + expect(result.outputSecond.output).toBeDefined(); + expect(result.outputSecond.output.length).toBeGreaterThan(0); + }); + + then('each BrainOutput has independent output', () => { + expect(result.outputFirst.output.length).toBeGreaterThan(0); + expect(result.outputSecond.output.length).toBeGreaterThan(0); + + // metrics are independent (not cumulative) + expect(result.outputFirst.metrics.size.chars.output).toBeGreaterThan(0); + expect(result.outputSecond.metrics.size.chars.output).toBeGreaterThan( + 0, + ); + }); + + // peer: genBrainCli.dispatch.withCompaction proves compaction splits into 2 episodes despite same session + then( + 'series has exactly 1 episode — without compaction, both asks share the same context window', + () => { + expect(result.seriesAfter).not.toBeNull(); + expect(result.seriesAfter!.episodes.length).toEqual(1); + }, + ); + + then('that episode has 2 exchanges (one per ask)', () => { + const episode = result.seriesAfter!.episodes[0]!; + expect(episode.exchanges.length).toEqual(2); + }); + + then('each exchange has its own input and output', () => { + const episode = result.seriesAfter!.episodes[0]!; + const [first, second] = episode.exchanges; + + // first exchange + expect(first!.input).toContain('ok'); + expect(first!.output.length).toBeGreaterThan(0); + + // second exchange + expect(second!.input).toContain('yes'); + expect(second!.output.length).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.withCompaction.integration.test.ts b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.withCompaction.integration.test.ts new file mode 100644 index 0000000..4eb5adc --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genBrainCli.dispatch.withCompaction.integration.test.ts @@ -0,0 +1,144 @@ +import { UnexpectedCodePathError } from 'helpful-errors'; +import { genTempDir, given, then, useBeforeAll, useThen, when } from 'test-fns'; + +import { genBrainCli } from '../rhachet/genBrainCli'; +import { genContextBrainAuthAnthropic } from './genContextBrainAuthAnthropic'; + +const SLUG_HAIKU = 'claude@anthropic/claude/haiku'; + +// compaction adds an extra summarization API call — allow generous timeout +jest.setTimeout(300_000); + +/** + * .what = race an async operation against a timeout + * .why = prevent indefinite hangs if the CLI stalls mid-compaction + */ +const withTimeout = async (input: { + promise: Promise; + ms: number; + label: string; +}): Promise => { + const timer = new Promise((_onDone, onFail) => + setTimeout( + () => onFail(new Error(`timeout after ${input.ms}ms: ${input.label}`)), + input.ms, + ), + ); + return Promise.race([input.promise, timer]); +}; + +describe('genBrainCli.dispatch.withCompaction', () => { + // use a temp dir as cwd — avoids repo hooks that inject massive context and cause infinite compaction loops + const scene = useBeforeAll(async () => { + const cwd = genTempDir({ slug: 'braincli-compact' }); + return { + cwd, + context: { + cwd, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw( + 'ANTHROPIC_API_KEY must be set via use.apikeys.sh', + ), + }, + }), + }, + }; + }); + + given( + '[case1] two asks with CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=1 to force compaction', + () => { + when('[t0] compaction fires between the two asks', () => { + const result = useThen('both asks succeed', async () => { + // force auto-compact at 1% of context window (~2K tokens) + const envBefore = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE; + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '1'; + + try { + const brain = await genBrainCli( + { slug: SLUG_HAIKU }, + scene.context, + ); + + // boot once — both asks on the same process + await brain.executor.boot({ mode: 'dispatch' }); + + // first ask — populates the context window past the 1% threshold + const outputFirst = await withTimeout({ + promise: brain.ask({ + prompt: 'respond with just the word ok', + }), + ms: 180_000, + label: 'first ask', + }); + + // second ask on the same boot — triggers auto-compact (context > 1%) + const outputSecond = await withTimeout({ + promise: brain.ask({ + prompt: 'respond with just the word yes', + }), + ms: 180_000, + label: 'second ask (with compaction)', + }); + + // capture series + const seriesAfter = brain.memory.series; + + // cleanup + brain.executor.kill(); + + return { outputFirst, outputSecond, seriesAfter }; + } finally { + // restore env + if (envBefore === undefined) { + delete process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE; + } else { + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = envBefore; + } + } + }); + + then('both outputs have non-empty text', () => { + expect(result.outputFirst.output.length).toBeGreaterThan(0); + expect(result.outputSecond.output.length).toBeGreaterThan(0); + }); + + // peer: genBrainCli.dispatch.sequential.sameboot proves without compaction both asks stay in 1 episode + then( + 'series has 2 episodes — compaction starts a new context window despite same session', + () => { + expect(result.seriesAfter).not.toBeNull(); + expect(result.seriesAfter!.episodes.length).toEqual(2); + }, + ); + + then('first episode has 1 exchange (before compaction)', () => { + const episode = result.seriesAfter!.episodes[0]!; + expect(episode.exchanges.length).toEqual(1); + }); + + then('second episode has 1 exchange (after compaction)', () => { + const episode = result.seriesAfter!.episodes[1]!; + expect(episode.exchanges.length).toEqual(1); + }); + + then( + 'both episode exids are suffixed to distinguish compaction-split episodes', + () => { + const first = result.seriesAfter!.episodes[0]!; + const second = result.seriesAfter!.episodes[1]!; + // both share the same session — suffixed with /0 and /1 + expect(first.exid).toMatch(/\/0$/); + expect(second.exid).toMatch(/\/1$/); + // same session prefix + const sessionPrefix = first.exid!.replace(/\/0$/, ''); + expect(second.exid).toEqual(`${sessionPrefix}/1`); + }, + ); + }); + }, + ); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/genBrainCli.guards.integration.test.ts b/src/_topublish/rhachet-brains-anthropic/genBrainCli.guards.integration.test.ts new file mode 100644 index 0000000..a1d2d9b --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genBrainCli.guards.integration.test.ts @@ -0,0 +1,136 @@ +import { BadRequestError, UnexpectedCodePathError } from 'helpful-errors'; +import { + genTempDir, + getError, + given, + then, + useBeforeAll, + when, +} from 'test-fns'; + +import { genBrainCli } from '../rhachet/genBrainCli'; +import { genContextBrainAuthAnthropic } from './genContextBrainAuthAnthropic'; + +const SLUG_HAIKU = 'claude@anthropic/claude/haiku'; + +describe('genBrainCli.guards', () => { + const scene = useBeforeAll(async () => { + const cwd = genTempDir({ slug: 'braincli-guards' }); + return { + cwd, + context: { + cwd, + ...genContextBrainAuthAnthropic({ + via: { + apiKey: + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw( + 'ANTHROPIC_API_KEY must be set via use.apikeys.sh', + ), + }, + }), + }, + }; + }); + + given('[case1] an invalid brain slug', () => { + when('[t0] genBrainCli is called', () => { + then('it throws a BadRequestError', async () => { + const error = await getError( + genBrainCli({ slug: 'invalid@unknown/slug' }, scene.context), + ); + expect(error).toBeInstanceOf(BadRequestError); + }); + }); + }); + + given('[case2] auth context variants', () => { + when('[t0] explicit apiKey auth is provided', () => { + then('it returns a handle', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + expect(brain).toBeDefined(); + expect(brain.executor.instance).toBeNull(); + }); + }); + + when('[t1] no auth context is provided (defaults to oauth)', () => { + then('it returns a handle', async () => { + const brain = await genBrainCli( + { slug: SLUG_HAIKU }, + { cwd: scene.cwd }, + ); + expect(brain).toBeDefined(); + expect(brain.executor.instance).toBeNull(); + }); + }); + + when('[t2] empty auth context is provided (defaults to oauth)', () => { + then('it returns a handle', async () => { + const brain = await genBrainCli( + { slug: SLUG_HAIKU }, + { cwd: scene.cwd, brain: {} }, + ); + expect(brain).toBeDefined(); + }); + }); + + when('[t3] invalid auth shape is provided', () => { + then('it throws a BadRequestError', async () => { + const error = await getError( + genBrainCli( + { slug: SLUG_HAIKU }, + { + cwd: scene.cwd, + brain: { auth: { anthropic: { bad: 'shape' } as any } }, + }, + ), + ); + expect(error).toBeInstanceOf(BadRequestError); + }); + }); + }); + + given('[case3] a handle that has not been booted', () => { + when('[t0] ask is called', () => { + then('it throws an error', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + const error = await getError(brain.ask({ prompt: 'hello' })); + expect(error).toBeInstanceOf(Error); + }); + }); + + when('[t1] kill is called', () => { + then('it is a safe no-op', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + // should not throw + brain.executor.kill(); + expect(brain.executor.instance).toBeNull(); + }); + }); + + when('[t2] act is called', () => { + then('it throws an error', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + const error = await getError(brain.act({ prompt: 'hello' })); + expect(error).toBeInstanceOf(Error); + }); + }); + + when('[t3] terminal.write is called', () => { + then('it throws an error', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + const error = await getError( + new Promise((onDone, onFail) => { + try { + brain.terminal.write('hello'); + onDone(); + } catch (err) { + onFail(err); + } + }), + ); + expect(error).toBeInstanceOf(Error); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/genBrainCli.interact.integration.test.ts b/src/_topublish/rhachet-brains-anthropic/genBrainCli.interact.integration.test.ts new file mode 100644 index 0000000..799ce5f --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genBrainCli.interact.integration.test.ts @@ -0,0 +1,411 @@ +import * as fs from 'fs'; +import { UnexpectedCodePathError } from 'helpful-errors'; +import * as path from 'path'; +import { + genTempDir, + getError, + given, + then, + useBeforeAll, + useThen, + when, +} from 'test-fns'; + +import { genBrainCli } from '../rhachet/genBrainCli'; +import { genContextBrainAuthAnthropic } from './genContextBrainAuthAnthropic'; + +const SLUG_HAIKU = 'claude@anthropic/claude/haiku'; + +/** + * .what = await until accumulated onData output matches a predicate + * .why = replace arbitrary timers with precise promise-based waits + */ +const awaitOutput = (input: { + brain: Awaited>; + predicate: (accumulated: string) => boolean; + timeoutMs: number; +}): Promise => + new Promise((onDone, onFail) => { + let accumulated = ''; + const timeout = setTimeout( + () => + onFail( + new Error( + `awaitOutput timed out after ${input.timeoutMs}ms. accumulated: ${accumulated}`, + ), + ), + input.timeoutMs, + ); + input.brain.terminal.onData((chunk) => { + accumulated += chunk; + + if (input.predicate(accumulated)) { + clearTimeout(timeout); + onDone(accumulated); + } + }); + }); + +/** + * .what = create an isolated claude config dir with onboard pre-completed + * .why = prevents first-run dialogs (theme, login method, trust) from appear + * in interact mode — the TUI boots straight to the prompt + * + * .note = claude code reads `~/.claude.json` (tied to HOME) for onboard state. + * by set HOME to a temp dir with a pre-written `.claude.json`, each + * brain instance gets isolated config and skips first-run entirely. + */ +const genClaudeHomeDir = (input: { apiKey: string }): string => { + const homeDir = genTempDir({ slug: 'braincli-home' }); + + // pre-write global user config — skip all first-run dialogs + const keySuffix = input.apiKey.slice(-20); + fs.writeFileSync( + path.join(homeDir, '.claude.json'), + JSON.stringify({ + hasCompletedOnboarding: true, + numStartups: 10, + customApiKeyResponses: { + approved: [keySuffix], + }, + }), + ); + + // pre-write config dir with api key approval + const claudeDir = path.join(homeDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeDir, 'config.json'), + JSON.stringify({ + primaryApiKey: input.apiKey, + customApiKeyResponses: { + approved: [keySuffix], + }, + }), + ); + + return homeDir; +}; + +/** + * .what = await TUI ready state, auto-dismiss any dialogs that appear first + * .why = interact mode may show per-workspace trust dialog and/or API key + * confirm dialog before the main TUI loads. this helper registers ONE + * listener that watches for and dismisses all known dialogs, then + * completes when the main TUI renders (detected via `shortcuts` token). + * + * .note = single listener avoids the race condition where a dialog is dismissed + * but the `shortcuts` listener is attached too late to capture the TUI render. + */ +const awaitTuiReady = (input: { + brain: Awaited>; + timeoutMs: number; +}): Promise => + new Promise((onDone, onFail) => { + let accumulated = ''; + let trustDismissed = false; + let apiKeyDismissed = false; + const timeout = setTimeout( + () => + onFail( + new Error( + `awaitTuiReady timed out after ${input.timeoutMs}ms. accumulated: ${accumulated}`, + ), + ), + input.timeoutMs, + ); + input.brain.terminal.onData((chunk) => { + accumulated += chunk; + + // auto-dismiss per-workspace trust dialog — "Yes, I trust" is pre-selected + if (!trustDismissed && accumulated.includes('trust')) { + trustDismissed = true; + input.brain.terminal.write('\r'); + } + + // auto-dismiss API key confirm dialog — "No" is pre-selected, press up then enter for "Yes" + if ( + !apiKeyDismissed && + accumulated.includes('API') && + accumulated.includes('key') + ) { + apiKeyDismissed = true; + // select "Yes" (option 1) — up arrow then enter + input.brain.terminal.write('\x1B[A\r'); + } + + // done when main TUI is ready + if (accumulated.includes('shortcuts')) { + clearTimeout(timeout); + onDone(accumulated); + } + }); + }); + +describe('genBrainCli.interact', () => { + const scene = useBeforeAll(async () => { + const cwd = genTempDir({ slug: 'braincli-interact' }); + const apiKey = + process.env.ANTHROPIC_API_KEY ?? + UnexpectedCodePathError.throw( + 'ANTHROPIC_API_KEY must be set via use.apikeys.sh', + ); + + // isolated claude config per test suite — skips first-run dialogs + const homeDir = genClaudeHomeDir({ apiKey }); + + return { + cwd, + context: { + cwd, + env: { HOME: homeDir }, + ...genContextBrainAuthAnthropic({ + via: { apiKey }, + }), + }, + }; + }); + + given('[case1] a handle booted in interact mode', () => { + when('[t0] boot interact mode', () => { + const result = useThen('interact boot succeeds', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot interact mode — first-run pre-completed via isolated HOME config + await brain.executor.boot({ mode: 'interact' }); + + const instanceMode = brain.executor.instance?.mode ?? null; + const instancePid = brain.executor.instance?.pid ?? null; + + // await initial PTY output via promise (not timer) + const initialOutput = await awaitOutput({ + brain, + predicate: (acc) => acc.length > 0, + timeoutMs: 15_000, + }); + + // cleanup + brain.executor.kill(); + + return { instanceMode, instancePid, initialOutput }; + }); + + then('instance mode is interact', () => { + expect(result.instanceMode).toEqual('interact'); + }); + + then('instance has a valid pid', () => { + expect(result.instancePid).not.toBeNull(); + expect(result.instancePid!).toBeGreaterThan(0); + }); + + then('terminal.onData receives PTY bytes', () => { + expect(result.initialOutput.length).toBeGreaterThan(0); + }); + }); + + when('[t1] terminal.write sends a prompt and receives a response', () => { + const result = useThen('write and read succeeds', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot interact mode — auto-dismiss any dialogs, await TUI ready + await brain.executor.boot({ mode: 'interact' }); + await awaitTuiReady({ brain, timeoutMs: 30_000 }); + + // let the TUI settle — it emits escape sequences after the prompt + await new Promise((r) => setTimeout(r, 2_000)); + + // write a prompt via terminal.write (PTY uses \r for Enter) + brain.terminal.write('respond with just the word pineapple\r'); + + // await the response via onData callback (not poll) + const responseOutput = await awaitOutput({ + brain, + predicate: (acc) => acc.toLowerCase().includes('pineapple'), + timeoutMs: 60_000, + }); + + // cleanup + brain.executor.kill(); + + return { responseOutput }; + }); + + then('response contains the expected word', () => { + expect(result.responseOutput.toLowerCase()).toContain('pineapple'); + }); + + then('response has non-trivial length', () => { + expect(result.responseOutput.length).toBeGreaterThan(10); + }); + }); + + when('[t2] terminal.resize does not crash in interact mode', () => { + const result = useThen('resize succeeds', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot interact mode — auto-dismiss any dialogs, await TUI ready + await brain.executor.boot({ mode: 'interact' }); + await awaitTuiReady({ brain, timeoutMs: 30_000 }); + + // resize the terminal — should not throw + brain.terminal.resize({ cols: 80, rows: 24 }); + brain.terminal.resize({ cols: 200, rows: 50 }); + + // verify process is still alive after resize + const instanceAfterResize = brain.executor.instance; + + // cleanup + brain.executor.kill(); + + return { instanceAfterResize }; + }); + + then('process is still alive after resize', () => { + expect(result.instanceAfterResize).not.toBeNull(); + expect(result.instanceAfterResize!.mode).toEqual('interact'); + }); + }); + + when('[t3.1] ask is called on interact handle', () => { + then('it throws an error', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + await brain.executor.boot({ mode: 'interact' }); + + const error = await getError(brain.ask({ prompt: 'hello' })); + + // cleanup + brain.executor.kill(); + + expect(error).toBeInstanceOf(Error); + }); + }); + + when('[t3.2] act is called on interact handle', () => { + then('it throws an error', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + await brain.executor.boot({ mode: 'interact' }); + + const error = await getError(brain.act({ prompt: 'hello' })); + + // cleanup + brain.executor.kill(); + + expect(error).toBeInstanceOf(Error); + }); + }); + + when( + '[t4] dispatch -> interact proves session resume via prior context recall', + () => { + const result = useThen( + 'brain recalls prior dispatch context in interact mode', + async () => { + const brain = await genBrainCli( + { slug: SLUG_HAIKU }, + scene.context, + ); + + // boot dispatch and tell the brain a unique word + await brain.executor.boot({ mode: 'dispatch' }); + await brain.ask({ + prompt: + 'remember this secret code word: flamingo. just say ok to confirm.', + }); + + // switch to interact mode (resumes the same session) + await brain.executor.boot({ mode: 'interact' }); + + // register the response listener BEFORE the TUI settles — captures all data from boot + const recallPromise = awaitOutput({ + brain, + predicate: (acc) => acc.toLowerCase().includes('flamingo'), + timeoutMs: 90_000, + }); + + // auto-dismiss any dialogs, await TUI ready + await awaitTuiReady({ brain, timeoutMs: 30_000 }); + + // guard: verify process survived the TUI boot + if (!brain.executor.instance) + throw new Error( + 'interact process exited before prompt could be sent', + ); + + // let the TUI settle + await new Promise((r) => setTimeout(r, 2_000)); + + // ask the brain to recall the word from the prior dispatch context + brain.terminal.write( + 'what was the secret code word I told you earlier? respond with just that one word\r', + ); + + // await the response + const recallOutput = await recallPromise; + + // cleanup + brain.executor.kill(); + + return { recallOutput }; + }, + ); + + then('brain recalls the word from prior dispatch context', () => { + expect(result.recallOutput.toLowerCase()).toContain('flamingo'); + }); + }, + ); + + when('[t5] dispatch -> interact preserves series', () => { + const result = useThen('mode switch preserves series', async () => { + const brain = await genBrainCli({ slug: SLUG_HAIKU }, scene.context); + + // boot dispatch and ask to populate series + await brain.executor.boot({ mode: 'dispatch' }); + await brain.ask({ prompt: 'respond with just the word ok' }); + const seriesBefore = brain.memory.series; + + // switch to interact mode + await brain.executor.boot({ mode: 'interact' }); + const modeAfterInteract = brain.executor.instance?.mode ?? null; + const seriesAfterInteract = brain.memory.series; + + // switch back to dispatch + await brain.executor.boot({ mode: 'dispatch' }); + const modeAfterDispatch = brain.executor.instance?.mode ?? null; + const seriesAfterDispatch = brain.memory.series; + + // cleanup + brain.executor.kill(); + + return { + seriesBefore, + modeAfterInteract, + seriesAfterInteract, + modeAfterDispatch, + seriesAfterDispatch, + }; + }); + + then('interact mode is set', () => { + expect(result.modeAfterInteract).toEqual('interact'); + }); + + then('series is preserved after switch to interact', () => { + expect(result.seriesAfterInteract?.exid).toEqual( + result.seriesBefore?.exid, + ); + }); + + then('dispatch mode is restored', () => { + expect(result.modeAfterDispatch).toEqual('dispatch'); + }); + + then('series is preserved after switch back to dispatch', () => { + expect(result.seriesAfterDispatch?.exid).toEqual( + result.seriesBefore?.exid, + ); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/genBrainCli.ts b/src/_topublish/rhachet-brains-anthropic/genBrainCli.ts new file mode 100644 index 0000000..103849b --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genBrainCli.ts @@ -0,0 +1,426 @@ +import { type ChildProcess, spawn } from 'child_process'; +import { BadRequestError, UnexpectedCodePathError } from 'helpful-errors'; +import type { BrainSeries } from 'rhachet'; +import { assure } from 'type-fns'; + +import type { BrainCli } from '../rhachet/BrainCli'; +import type { ContextBrainAuth } from '../rhachet/ContextBrainAuth'; +import { + type BrainAuthAnthropic, + isBrainAuthAnthropic, +} from './BrainAuthAnthropic'; +import { getOneAnthropicBrainCliConfig } from './BrainCli.config'; +import { getOneBrainOutputFromStreamJson } from './getOneBrainOutputFromStreamJson'; +import { getOneDispatchArgs } from './getOneDispatchArgs'; +import { getOneInteractArgs } from './getOneInteractArgs'; + +/** + * .what = lookup the CLI entry point from the installed @anthropic-ai/claude-code package + * .why = guarantees the pinned prod dep binary is used — never falls back to system PATH + * + * .note = require.resolve is a node builtin for module lookup — not the forbidden "resolve" verb + */ +const getOneClaudeCliPath = (): string => { + try { + return require.resolve('@anthropic-ai/claude-code/cli.js'); + } catch (error) { + throw new UnexpectedCodePathError( + '@anthropic-ai/claude-code is not installed. this is a prod dependency of rhachet-brains-anthropic', + { error: error instanceof Error ? error.message : String(error) }, + ); + } +}; + +/** + * .what = construct a BrainCli handle for a claude code CLI process + * .why = the anthropic supplier — wires spawn, dispatch, terminal, and series management + */ +export const genBrainCli = async ( + input: { slug: string }, + context: { cwd: string; env?: Record } & ContextBrainAuth<{ + anthropic?: BrainAuthAnthropic; + }>, +): Promise => { + // derive config from slug — validates and fails fast if unrecognized + const config = getOneAnthropicBrainCliConfig({ slug: input.slug }); + + // derive anthropic auth: use explicit context if provided, otherwise default to oauth + const authCandidate = context.brain?.auth?.['anthropic']; + const authAnthropic: BrainAuthAnthropic = (() => { + // no auth provided — default to oauth + if (!authCandidate) + return assure({ via: { oauth: true } }, isBrainAuthAnthropic); + + // caller provided auth — validate shape via type guard, fail fast if invalid + if (!isBrainAuthAnthropic(authCandidate)) + throw new BadRequestError( + 'context.brain.auth.anthropic has invalid shape', + { auth: context.brain?.auth }, + ); + return authCandidate; + })(); + + // mutable handle state + let instance: BrainCli['executor']['instance'] = null; + let series: BrainSeries | null = null; + let lastTaskMode: 'ask' | 'act' | null = null; + let childProcess: ChildProcess | null = null; + let ptyProcess: ReturnType | null = + null; + let resumedFromExid: string | null = null; + + // build spawn env from derived auth — strip ambient keys to prevent leaks + const spawnEnv = (() => { + const { + CLAUDECODE: _stripClaudeCode, + ANTHROPIC_API_KEY: _stripKey, + ...baseEnv + } = process.env; + + // merge caller-provided env overrides (e.g., HOME for config isolation) + const envWithOverrides = { ...baseEnv, ...context.env }; + + // api key mode — auth-derived key always wins over overrides + if (authAnthropic.via.apiKey) + return { + ...envWithOverrides, + ANTHROPIC_API_KEY: authAnthropic.via.apiKey, + }; + + // oauth mode — clean env, claude CLI handles its own oauth + return envWithOverrides; + })(); + + // lookup pinned CLI entry point — fail fast if not installed + const claudeCliPath = getOneClaudeCliPath(); + + // event callback registries — persist across process reboots + const dataListeners: Array<(chunk: string) => void> = []; + const exitListeners: Array< + (info: { code: number; signal: string | null }) => void + > = []; + + /** + * .what = wire child process events to terminal callbacks + * .why = unify event dispatch for both spawn modes + */ + const wireChildProcessHooks = (proc: ChildProcess): void => { + // stdout data -> dataListeners + proc.stdout?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + for (const cb of dataListeners) cb(text); + }); + + // stderr data -> dataListeners (interleave for observability) + proc.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + for (const cb of dataListeners) cb(text); + }); + + // exit -> clear instance, fire exitListeners (only if this proc is still current) + proc.on('exit', (code, signal) => { + if (childProcess !== proc) return; + instance = null; + childProcess = null; + const info = { + code: code ?? 1, + signal: signal ?? null, + }; + for (const cb of exitListeners) cb(info); + }); + }; + + /** + * .what = wire pty process events to terminal callbacks + * .why = unify event dispatch for interact mode + */ + const wirePtyProcessHooks = ( + proc: ReturnType, + ): void => { + // onData -> dataListeners + proc.onData((data: string) => { + for (const cb of dataListeners) cb(data); + }); + + // onExit -> clear instance, fire exitListeners (only if this proc is still current) + proc.onExit((exitInfo: { exitCode: number; signal?: number }) => { + if (ptyProcess !== proc) return; + instance = null; + ptyProcess = null; + const info = { + code: exitInfo.exitCode, + signal: exitInfo.signal != null ? String(exitInfo.signal) : null, + }; + for (const cb of exitListeners) cb(info); + }); + }; + + /** + * .what = kill the current process if alive + * .why = clean teardown before reboot or on explicit kill + */ + const killCurrentProcess = (): void => { + // send SIGTERM but do NOT null childProcess/ptyProcess here + // the exit handler will null them and fire exitListeners + // this prevents the race where boot-after-kill spawns a new process + // whose state gets clobbered by the old process's exit handler + if (childProcess) childProcess.kill('SIGTERM'); + if (ptyProcess) ptyProcess.kill(); + instance = null; + }; + + // the handle + const handle: BrainCli = { + /** + * .what = dispatch a read-only task + * .why = enforced via restricted --allowedTools at spawn time + */ + ask: async (askInput) => { + // guard: must be in dispatch mode + if (!instance) + UnexpectedCodePathError.throw( + 'cannot ask: no live process. call executor.boot first', + ); + if (instance.mode !== 'dispatch') + UnexpectedCodePathError.throw( + 'cannot ask: handle is in interact mode. boot dispatch first', + ); + + // respawn with ask tools if prior boot used act tools + if (lastTaskMode !== 'ask') { + lastTaskMode = 'ask'; + await handle.executor.boot({ mode: 'dispatch' }); + } + + // write nd-JSON message to stdin + if (!childProcess?.stdin) + UnexpectedCodePathError.throw('dispatch process has no stdin'); + const message = JSON.stringify({ + type: 'user', + message: { role: 'user', content: askInput.prompt }, + session_id: series?.exid ?? undefined, + }); + childProcess.stdin.write(message + '\n'); + + // collect BrainOutput from stream — pass resumedFromExid only on first call after resume-boot + const brainOutput = await getOneBrainOutputFromStreamJson({ + prompt: askInput.prompt, + stdout: childProcess.stdout!, + spec: config.spec, + seriesPrior: series, + resumedFromExid, + }); + + // clear resume flag after first call — subsequent calls on same boot are not "resumed" + resumedFromExid = null; + + // update series + series = brainOutput.series; + + return brainOutput; + }, + + /** + * .what = dispatch a full-tool task + * .why = enforced via full --allowedTools at spawn time + */ + act: async (actInput) => { + // guard: must be in dispatch mode + if (!instance) + UnexpectedCodePathError.throw( + 'cannot act: no live process. call executor.boot first', + ); + if (instance.mode !== 'dispatch') + UnexpectedCodePathError.throw( + 'cannot act: handle is in interact mode. boot dispatch first', + ); + + // respawn with act tools if prior boot used ask tools + if (lastTaskMode !== 'act') { + lastTaskMode = 'act'; + await handle.executor.boot({ mode: 'dispatch' }); + } + + // write nd-JSON message to stdin + if (!childProcess?.stdin) + UnexpectedCodePathError.throw('dispatch process has no stdin'); + const message = JSON.stringify({ + type: 'user', + message: { role: 'user', content: actInput.prompt }, + session_id: series?.exid ?? undefined, + }); + childProcess.stdin.write(message + '\n'); + + // collect BrainOutput from stream — pass resumedFromExid only on first call after resume-boot + const brainOutput = await getOneBrainOutputFromStreamJson({ + prompt: actInput.prompt, + stdout: childProcess.stdout!, + spec: config.spec, + seriesPrior: series, + resumedFromExid, + }); + + // clear resume flag after first call — subsequent calls on same boot are not "resumed" + resumedFromExid = null; + + // update series + series = brainOutput.series; + + return brainOutput; + }, + + // durable state + memory: { + get series() { + return series; + }, + set series(v) { + series = v; + }, + }, + + // process lifecycle + executor: { + get instance() { + return instance; + }, + + /** + * .what = boot or reboot the handle into a mode + * .why = spawns the CLI process with appropriate args for the mode + */ + boot: async (bootInput) => { + // kill extant process if alive + killCurrentProcess(); + + // detach old process refs so their async exit handlers won't clobber new state + childProcess = null; + ptyProcess = null; + + // track whether this boot uses --resume (first call after resume-boot tolerates compaction) + resumedFromExid = series?.exid ?? null; + + if (bootInput.mode === 'dispatch') { + // compute args for dispatch mode — default to ask tools if no prior task mode + const taskMode = lastTaskMode ?? 'ask'; + lastTaskMode = taskMode; + const args = getOneDispatchArgs({ + config, + taskMode, + series, + }); + + // spawn via child_process with pipe stdio — uses pinned cli.js via node + childProcess = spawn(process.execPath, [claudeCliPath, ...args], { + cwd: context.cwd, + env: spawnEnv, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // update instance state + instance = { + pid: childProcess.pid!, + mode: 'dispatch', + }; + + // wire event hooks + wireChildProcessHooks(childProcess); + return; + } + + if (bootInput.mode === 'interact') { + // compute args for interact mode + const args = getOneInteractArgs({ config, series }); + + // spawn via @lydell/node-pty for raw PTY — uses pinned cli.js via node + const nodePty = await import('@lydell/node-pty'); + ptyProcess = nodePty.spawn( + process.execPath, + [claudeCliPath, ...args], + { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: context.cwd, + env: spawnEnv as Record, + }, + ); + + // update instance state + instance = { + pid: ptyProcess.pid, + mode: 'interact', + }; + + // wire event hooks + wirePtyProcessHooks(ptyProcess); + return; + } + + UnexpectedCodePathError.throw('invalid boot mode', { + mode: bootInput.mode, + }); + }, + + /** + * .what = kill the current process + * .why = clean shutdown — no-op if not booted + */ + kill: () => { + killCurrentProcess(); + }, + }, + + // terminal i/o + terminal: { + /** + * .what = write raw data to process stdin + * .why = relay user keystrokes or protocol data + */ + write: (data) => { + if (!instance) + UnexpectedCodePathError.throw('cannot write: no live process'); + if (instance.mode === 'dispatch' && childProcess?.stdin) { + childProcess.stdin.write(data); + return; + } + if (instance.mode === 'interact' && ptyProcess) { + ptyProcess.write(data); + return; + } + UnexpectedCodePathError.throw( + 'cannot write: process handle not available', + ); + }, + + /** + * .what = resize the terminal + * .why = interact mode needs terminal dimensions; dispatch is no-op + */ + resize: (resizeInput) => { + if (ptyProcess) { + ptyProcess.resize(resizeInput.cols, resizeInput.rows); + } + // dispatch mode: no-op (no terminal to resize) + }, + + /** + * .what = register a data callback + * .why = persists across process reboots — register once, survive reboots + */ + onData: (cb) => { + dataListeners.push(cb); + }, + + /** + * .what = register an exit callback + * .why = persists across process reboots — register once, survive reboots + */ + onExit: (cb) => { + exitListeners.push(cb); + }, + }, + }; + + return handle; +}; diff --git a/src/_topublish/rhachet-brains-anthropic/genContextBrainAuthAnthropic.ts b/src/_topublish/rhachet-brains-anthropic/genContextBrainAuthAnthropic.ts new file mode 100644 index 0000000..f14da2d --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/genContextBrainAuthAnthropic.ts @@ -0,0 +1,12 @@ +import type { ContextBrainAuth } from '../rhachet/ContextBrainAuth'; +import type { BrainAuthAnthropic } from './BrainAuthAnthropic'; + +/** + * .what = construct the nested auth context from a BrainAuthAnthropic via shape + * .why = shared test helper — all integration tests use the same pattern + */ +export const genContextBrainAuthAnthropic = (input: { + via: BrainAuthAnthropic['via']; +}): ContextBrainAuth<{ anthropic: BrainAuthAnthropic }> => ({ + brain: { auth: { anthropic: { via: input.via } } }, +}); diff --git a/src/_topublish/rhachet-brains-anthropic/getOneBrainOutputFromStreamJson.test.ts b/src/_topublish/rhachet-brains-anthropic/getOneBrainOutputFromStreamJson.test.ts new file mode 100644 index 0000000..d5ad7ec --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/getOneBrainOutputFromStreamJson.test.ts @@ -0,0 +1,424 @@ +import { UnexpectedCodePathError } from 'helpful-errors'; +import { BrainEpisode, BrainExchange, BrainSeries } from 'rhachet'; +import { Readable } from 'stream'; +import { getError, given, then, useThen, when } from 'test-fns'; + +import { getOneAnthropicBrainCliConfig } from './BrainCli.config'; +import { getOneBrainOutputFromStreamJson } from './getOneBrainOutputFromStreamJson'; + +const spec = getOneAnthropicBrainCliConfig({ + slug: 'claude@anthropic/claude/opus/v4.5', +}).spec; + +/** + * .what = create a readable stream from nd-JSON lines + * .why = synthetic test data for the stream parser + */ +const getOneStreamFromLines = (lines: string[]): Readable => { + const stream = new Readable({ read() {} }); + // push all lines as a single chunk (simulates buffered read) + const data = lines.join('\n') + '\n'; + process.nextTick(() => { + stream.push(data); + stream.push(null); + }); + return stream; +}; + +describe('getOneBrainOutputFromStreamJson', () => { + given('a stream with system, assistant, and result events', () => { + when('parsed', () => { + const result = useThen('it returns a BrainOutput', async () => { + const lines = [ + JSON.stringify({ + type: 'system', + session_id: 'sess-abc-123', + }), + JSON.stringify({ + type: 'assistant', + message: { + content: [{ type: 'text', text: 'the answer is 4' }], + }, + session_id: 'sess-abc-123', + }), + JSON.stringify({ + type: 'result', + result: 'the answer is 4', + session_id: 'sess-abc-123', + cost_usd: 0.003, + duration_ms: 1234, + is_error: false, + }), + ]; + return getOneBrainOutputFromStreamJson({ + prompt: 'what is 2+2?', + stdout: getOneStreamFromLines(lines), + spec, + seriesPrior: null, + resumedFromExid: null, + }); + }); + + then('output contains the text', () => { + expect(result.output).toEqual('the answer is 4'); + }); + + then('series.exid is the session_id', () => { + expect(result.series).not.toBeNull(); + expect(result.series!.exid).toEqual('sess-abc-123'); + }); + + then('episode.exid is the session_id', () => { + expect(result.episode.exid).toEqual('sess-abc-123'); + }); + + then('metrics.cost.time reflects duration', () => { + expect(result.metrics.cost.time).toEqual({ + milliseconds: 1234, + }); + }); + + then('metrics.cost.cash.total reflects CLI cost', () => { + expect(result.metrics.cost.cash.total).toContain('0.003'); + }); + }); + }); + + given('a stream with stream_event wrappers and token counts', () => { + when('parsed', () => { + const result = useThen('it returns a BrainOutput', async () => { + const lines = [ + JSON.stringify({ + type: 'system', + session_id: 'sess-def-456', + }), + JSON.stringify({ + type: 'stream_event', + event: { + type: 'message_start', + message: { usage: { input_tokens: 500 } }, + }, + }), + JSON.stringify({ + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'hello ' }, + }, + }), + JSON.stringify({ + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'world' }, + }, + }), + JSON.stringify({ + type: 'stream_event', + event: { + type: 'message_delta', + usage: { + input_tokens: 500, + output_tokens: 20, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 100, + }, + }, + }), + JSON.stringify({ + type: 'result', + result: 'hello world', + session_id: 'sess-def-456', + cost_usd: 0.001, + duration_ms: 500, + is_error: false, + }), + ]; + return getOneBrainOutputFromStreamJson({ + prompt: 'say hello world', + stdout: getOneStreamFromLines(lines), + spec, + seriesPrior: null, + resumedFromExid: null, + }); + }); + + then('metrics.size.tokens.input reflects message_start', () => { + expect(result.metrics.size.tokens.input).toEqual(500); + }); + + then('metrics.size.tokens.output reflects message_delta', () => { + expect(result.metrics.size.tokens.output).toEqual(20); + }); + + then('metrics.size.tokens.cache reflects message_delta', () => { + expect(result.metrics.size.tokens.cache.set).toEqual(10); + expect(result.metrics.size.tokens.cache.get).toEqual(100); + }); + + then('output text is the result text', () => { + expect(result.output).toEqual('hello world'); + }); + }); + }); + + given('a stream that ends without a result event', () => { + when('parsed', () => { + const result = useThen('it finalizes on stream end', async () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { + content: [{ type: 'text', text: 'partial output' }], + }, + }), + ]; + return getOneBrainOutputFromStreamJson({ + prompt: 'test prompt', + stdout: getOneStreamFromLines(lines), + spec, + seriesPrior: null, + resumedFromExid: null, + }); + }); + + then('output contains the accumulated text', () => { + expect(result.output).toEqual('partial output'); + }); + + then('series.exid is null', () => { + expect(result.series!.exid).toBeNull(); + }); + }); + }); + + given('a stream that emits an error', () => { + when('parsed', () => { + then('it throws an UnexpectedCodePathError', async () => { + const stream = new Readable({ read() {} }); + + // emit error after a tick + process.nextTick(() => { + stream.destroy(new Error('connection reset')); + }); + + const error = await getError( + getOneBrainOutputFromStreamJson({ + prompt: 'test prompt', + stdout: stream, + spec, + seriesPrior: null, + resumedFromExid: null, + }), + ); + expect(error).toBeInstanceOf(UnexpectedCodePathError); + expect((error as Error).message).toContain('stream error'); + }); + }); + }); + + given( + '[case5] a prior series with same session_id (same context window)', + () => { + when('[t0] parsed with a result from the same session', () => { + const result = useThen( + 'it appends exchange to latest episode and replaces it', + async () => { + const priorExchange = new BrainExchange({ + hash: 'exchange-1-hash', + input: 'first prompt', + output: 'first output', + exid: null, + }); + const priorEpisode = new BrainEpisode({ + hash: 'episode-1-hash', + exid: 'sess-same', + exchanges: [priorExchange], + }); + const seriesPrior = new BrainSeries({ + hash: 'series-hash', + exid: 'sess-same', + episodes: [priorEpisode], + }); + const lines = [ + JSON.stringify({ + type: 'result', + result: 'second output', + session_id: 'sess-same', + cost_usd: 0.002, + duration_ms: 800, + is_error: false, + }), + ]; + return getOneBrainOutputFromStreamJson({ + prompt: 'second prompt', + stdout: getOneStreamFromLines(lines), + spec, + seriesPrior, + resumedFromExid: null, + }); + }, + ); + + then( + 'series still has exactly 1 episode (replaced, not appended)', + () => { + expect(result.series!.episodes.length).toEqual(1); + }, + ); + + then('episode has 2 exchanges (prior + new)', () => { + expect(result.episode.exchanges.length).toEqual(2); + expect(result.episode.exchanges[0]!.input).toEqual('first prompt'); + expect(result.episode.exchanges[1]!.input).toEqual('second prompt'); + }); + + then('episode exid matches session_id', () => { + expect(result.episode.exid).toEqual('sess-same'); + }); + + then('series exid matches session_id', () => { + expect(result.series!.exid).toEqual('sess-same'); + }); + }); + }, + ); + + given( + '[case6] a prior series with different session_id (new context window)', + () => { + when('[t0] parsed with a result from a new session', () => { + const result = useThen( + 'it appends a new episode to the series', + async () => { + const priorExchange = new BrainExchange({ + hash: 'exchange-1-hash', + input: 'first prompt', + output: 'first output', + exid: null, + }); + const priorEpisode = new BrainEpisode({ + hash: 'episode-1-hash', + exid: 'sess-old', + exchanges: [priorExchange], + }); + const seriesPrior = new BrainSeries({ + hash: 'series-hash', + exid: 'sess-old', + episodes: [priorEpisode], + }); + const lines = [ + JSON.stringify({ + type: 'result', + result: 'new context output', + session_id: 'sess-new', + cost_usd: 0.002, + duration_ms: 800, + is_error: false, + }), + ]; + return getOneBrainOutputFromStreamJson({ + prompt: 'new context prompt', + stdout: getOneStreamFromLines(lines), + spec, + seriesPrior, + resumedFromExid: null, + }); + }, + ); + + then('series has 2 episodes (old + new)', () => { + expect(result.series!.episodes.length).toEqual(2); + }); + + then('first episode is the prior one (untouched)', () => { + expect(result.series!.episodes[0]!.exid).toEqual('sess-old'); + expect(result.series!.episodes[0]!.exchanges.length).toEqual(1); + }); + + then('second episode is the new one with 1 exchange', () => { + expect(result.series!.episodes[1]!.exid).toEqual('sess-new'); + expect(result.series!.episodes[1]!.exchanges.length).toEqual(1); + expect(result.series!.episodes[1]!.exchanges[0]!.input).toEqual( + 'new context prompt', + ); + }); + + then('series exid updates to the new session_id', () => { + expect(result.series!.exid).toEqual('sess-new'); + }); + }); + }, + ); + + given( + '[case7] a prior series with same session_id but compact_boundary event (compaction)', + () => { + when('[t0] parsed with a compact_boundary before the result', () => { + const result = useThen( + 'it starts a new episode despite same session_id', + async () => { + const priorExchange = new BrainExchange({ + hash: 'exchange-1-hash', + input: 'first prompt', + output: 'first output', + exid: null, + }); + const priorEpisode = new BrainEpisode({ + hash: 'episode-1-hash', + exid: 'sess-same', + exchanges: [priorExchange], + }); + const seriesPrior = new BrainSeries({ + hash: 'series-hash', + exid: 'sess-same', + episodes: [priorEpisode], + }); + const lines = [ + JSON.stringify({ + type: 'system', + session_id: 'sess-same', + subtype: 'compact_boundary', + }), + JSON.stringify({ + type: 'result', + result: 'post-compaction output', + session_id: 'sess-same', + cost_usd: 0.002, + duration_ms: 600, + is_error: false, + }), + ]; + return getOneBrainOutputFromStreamJson({ + prompt: 'post-compaction prompt', + stdout: getOneStreamFromLines(lines), + spec, + seriesPrior, + resumedFromExid: null, + }); + }, + ); + + then('series has 2 episodes (pre + post compaction)', () => { + expect(result.series!.episodes.length).toEqual(2); + }); + + then('first episode is the prior one (retroactively suffixed)', () => { + expect(result.series!.episodes[0]!.exid).toEqual('sess-same/0'); + expect(result.series!.episodes[0]!.exchanges.length).toEqual(1); + }); + + then( + 'second episode has suffixed exid to distinguish from pre-compaction episode', + () => { + expect(result.series!.episodes[1]!.exid).toEqual('sess-same/1'); + expect(result.series!.episodes[1]!.exchanges.length).toEqual(1); + expect(result.series!.episodes[1]!.exchanges[0]!.input).toEqual( + 'post-compaction prompt', + ); + }, + ); + }); + }, + ); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/getOneBrainOutputFromStreamJson.ts b/src/_topublish/rhachet-brains-anthropic/getOneBrainOutputFromStreamJson.ts new file mode 100644 index 0000000..3439ce4 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/getOneBrainOutputFromStreamJson.ts @@ -0,0 +1,433 @@ +import { createHash } from 'crypto'; +import { UnexpectedCodePathError } from 'helpful-errors'; +import { + BrainEpisode, + BrainExchange, + BrainOutput, + BrainOutputMetrics, + BrainSeries, + type BrainSpec, +} from 'rhachet'; +import type { Readable } from 'stream'; + +/** + * .what = sha256 hash a value + * .why = used to construct hash fields on domain objects + */ +const sha256 = (value: string): string => + createHash('sha256').update(value).digest('hex'); + +/** + * .what = extract the numeric value from an IsoPrice + * .why = IsoPrice is a branded string like '$0.000001' — need the number for multiplication + */ +const getOneNumericFromIsoPrice = (price: string): number => { + const cleaned = price.replace(/[^0-9.-]/g, ''); + return Number.parseFloat(cleaned) || 0; +}; + +/** + * .what = format a number as an IsoPrice + * .why = construct IsoPrice-shaped values from computed costs + */ +const getOneIsoPrice = (value: number): string => `$${value.toFixed(10)}`; + +/** + * .what = parse nd-JSON event stream from claude `-p --output-format stream-json` into a typed BrainOutput + * .why = the core transform — turns raw vendor stream into the contract's return type + * + * .note = the stream emits these top-level event types: + * - system: init metadata + * - assistant: assistant turn content + * - result: final result with session_id, cost, and output text + * - stream_event: wraps anthropic API events (message_start, content_block_delta, message_delta, message_stop) + * + * .note = detect `result` event as completion — do NOT wait for process exit (CLI may hang after final event) + */ +export const getOneBrainOutputFromStreamJson = (input: { + prompt: string; + stdout: Readable; + spec: BrainSpec; + seriesPrior: BrainSeries | null; + resumedFromExid: string | null; +}): Promise> => { + return new Promise>((onResolve, onReject) => { + // accumulate parsed state + let textAccumulated = ''; + let sessionId: string | null = null; + let tokensInput = 0; + let tokensOutput = 0; + let tokensCacheGet = 0; + let tokensCacheSet = 0; + let costUsd: number | null = null; + let durationMs: number | null = null; + let resultText: string | null = null; + let compactionDetected = false; + let resolved = false; + + // line buffer for nd-JSON parse + let buffer = ''; + + /** + * .what = process a complete nd-JSON line + * .why = dispatch by event type to accumulate text, tokens, session_id + */ + const processLine = (line: string): void => { + // skip empty lines + const trimmed = line.trim(); + if (!trimmed) return; + + // parse JSON + let event: Record; + try { + event = JSON.parse(trimmed); + } catch { + // skip non-JSON lines (e.g., raw stderr bleed) + return; + } + + const eventType = event.type as string | undefined; + + // handle result event — final output with session_id and cost + if (eventType === 'result') { + sessionId = (event.session_id as string) ?? sessionId; + costUsd = (event.cost_usd as number) ?? costUsd; + durationMs = (event.duration_ms as number) ?? durationMs; + resultText = (event.result as string) ?? resultText; + + // extract usage from result event if present + const usage = event.usage as + | { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + } + | undefined; + if (usage) { + if (usage.input_tokens) tokensInput = usage.input_tokens; + if (usage.output_tokens) tokensOutput = usage.output_tokens; + if (usage.cache_creation_input_tokens) + tokensCacheSet = usage.cache_creation_input_tokens; + if (usage.cache_read_input_tokens) + tokensCacheGet = usage.cache_read_input_tokens; + } + + // also check num_turns for token fallback + const totalCost = event.total_cost as number | undefined; + if (totalCost != null && costUsd == null) costUsd = totalCost; + + // result event signals completion + finalize(); + return; + } + + // handle assistant event — may contain content blocks directly + if (eventType === 'assistant') { + const message = event.message as + | { + content?: Array<{ type: string; text?: string }>; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; + } + | undefined; + if (message?.content) { + for (const block of message.content) { + if (block.type === 'text' && block.text) { + textAccumulated += block.text; + } + } + } + if (message?.usage) { + if (message.usage.input_tokens) + tokensInput = message.usage.input_tokens; + if (message.usage.output_tokens) + tokensOutput = message.usage.output_tokens; + if (message.usage.cache_creation_input_tokens) + tokensCacheSet = message.usage.cache_creation_input_tokens; + if (message.usage.cache_read_input_tokens) + tokensCacheGet = message.usage.cache_read_input_tokens; + } + sessionId = (event.session_id as string) ?? sessionId; + return; + } + + // handle stream_event — wraps anthropic API events + if (eventType === 'stream_event') { + const inner = event.event as Record | undefined; + if (!inner) return; + processStreamEvent(inner); + return; + } + + // handle system event — may contain session_id or compact_boundary + if (eventType === 'system') { + sessionId = (event.session_id as string) ?? sessionId; + if ((event.subtype as string) === 'compact_boundary') + compactionDetected = true; + return; + } + + // handle top-level compact_boundary event (alternate shape) + if (eventType === 'compact_boundary') { + compactionDetected = true; + return; + } + }; + + /** + * .what = process an inner anthropic API stream event + * .why = extract tokens from message_start/message_delta, text from content_block_delta + */ + const processStreamEvent = (event: Record): void => { + const innerType = event.type as string | undefined; + + // message_start — initial input token count + if (innerType === 'message_start') { + const message = event.message as + | { usage?: { input_tokens?: number } } + | undefined; + if (message?.usage?.input_tokens) { + tokensInput = message.usage.input_tokens; + } + return; + } + + // content_block_delta — accumulate text + if (innerType === 'content_block_delta') { + const delta = event.delta as + | { type?: string; text?: string } + | undefined; + if (delta?.type === 'text_delta' && delta.text) { + textAccumulated += delta.text; + } + return; + } + + // message_delta — cumulative token counts + if (innerType === 'message_delta') { + const usage = event.usage as + | { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + } + | undefined; + if (usage) { + if (usage.input_tokens) tokensInput = usage.input_tokens; + if (usage.output_tokens) tokensOutput = usage.output_tokens; + if (usage.cache_creation_input_tokens) + tokensCacheSet = usage.cache_creation_input_tokens; + if (usage.cache_read_input_tokens) + tokensCacheGet = usage.cache_read_input_tokens; + } + return; + } + }; + + /** + * .what = detach all listeners from the stdout stream + * .why = prevent listener leak when the same stream is reused across calls + */ + const detachListeners = (): void => { + input.stdout.removeListener('data', onDataHandler); + input.stdout.removeListener('end', onEndHandler); + input.stdout.removeListener('error', onErrorHandler); + }; + + /** + * .what = construct the BrainOutput from accumulated state + * .why = consolidate all parsed data into the contract return type + */ + const finalize = (): void => { + if (resolved) return; + resolved = true; + detachListeners(); + + // prefer result text over accumulated text (result is the clean final output) + const outputText = resultText ?? textAccumulated; + + // derive cost from tokens x spec rates + const rates = input.spec.cost.cash; + const costInput = + getOneNumericFromIsoPrice(rates.input as string) * tokensInput; + const costOutput = + getOneNumericFromIsoPrice(rates.output as string) * tokensOutput; + const costCacheGet = + getOneNumericFromIsoPrice(rates.cache.get as string) * tokensCacheGet; + const costCacheSet = + getOneNumericFromIsoPrice(rates.cache.set as string) * tokensCacheSet; + + // total: prefer CLI-reported cost, fallback to derived + const costTotalNum = + costUsd ?? costInput + costOutput + costCacheGet + costCacheSet; + + // derive time cost + const costTime = + durationMs != null ? { milliseconds: durationMs } : { milliseconds: 0 }; + + // construct metrics + const metrics = new BrainOutputMetrics({ + size: { + tokens: { + input: tokensInput, + output: tokensOutput, + cache: { get: tokensCacheGet, set: tokensCacheSet }, + }, + chars: { + input: input.prompt.length, + output: outputText.length, + cache: { get: 0, set: 0 }, + }, + }, + cost: { + time: costTime, + cash: { + total: getOneIsoPrice(costTotalNum), + deets: { + input: getOneIsoPrice(costInput), + output: getOneIsoPrice(costOutput), + cache: { + get: getOneIsoPrice(costCacheGet), + set: getOneIsoPrice(costCacheSet), + }, + }, + }, + }, + } as BrainOutputMetrics); + + // construct exchange + const exchange = new BrainExchange({ + hash: sha256(`${input.prompt}:${outputText}`), + input: input.prompt, + output: outputText, + exid: null, + }); + + // decide whether this exchange belongs to the latest episode or starts a new one + const episodesPrior = input.seriesPrior?.episodes ?? []; + const episodeLatest = episodesPrior[episodesPrior.length - 1] ?? null; + // same context window if: session matches AND (no compaction OR compaction is expected from resume) + const isSessionMatch = + episodeLatest != null && + sessionId != null && + episodeLatest.exid === sessionId; + const isResumedSession = + episodeLatest != null && + input.resumedFromExid != null && + (episodeLatest.exid === input.resumedFromExid || + episodeLatest.exid?.startsWith(`${input.resumedFromExid}/`)); + const isSameContextWindow = + (isSessionMatch || isResumedSession) && + (!compactionDetected || isResumedSession); + + // derive episode exid — suffix with index when compaction splits a session into multiple episodes + const isCompactionSplit = + !isSameContextWindow && + compactionDetected && + sessionId != null && + episodeLatest?.exid != null && + (episodeLatest.exid === sessionId || + episodeLatest.exid.startsWith(`${sessionId}/`)); + const episodeExid = isCompactionSplit + ? `${sessionId}/${episodesPrior.length}` + : sessionId; + + // retroactively suffix prior episodes when compaction splits a session (e.g., sess-abc → sess-abc/0) + const episodesPriorSuffixed = isCompactionSplit + ? episodesPrior.map((ep, idx) => { + if (ep.exid === sessionId) { + return new BrainEpisode({ + hash: ep.hash, + exid: `${sessionId}/${idx}`, + exchanges: ep.exchanges, + }); + } + return ep; + }) + : episodesPrior; + + // construct episode — append exchange to latest if same context window, else start fresh + const episode = isSameContextWindow + ? new BrainEpisode({ + hash: sha256( + `${sessionId}:${[...episodeLatest.exchanges, exchange].map((e) => e.hash).join(':')}`, + ), + exid: episodeExid, + exchanges: [...episodeLatest.exchanges, exchange], + }) + : new BrainEpisode({ + hash: sha256(`${sessionId ?? 'ephemeral'}:${exchange.hash}`), + exid: episodeExid, + exchanges: [exchange], + }); + + // construct series — replace latest episode if same context window, else append new one + const episodesForSeries = isSameContextWindow + ? [...episodesPriorSuffixed.slice(0, -1), episode] + : [...episodesPriorSuffixed, episode]; + + const series = new BrainSeries({ + hash: sha256(sessionId ?? 'ephemeral'), + exid: sessionId, + episodes: episodesForSeries, + }); + + // construct output + const brainOutput = new BrainOutput({ + output: outputText, + metrics, + episode, + series, + }); + + onResolve(brainOutput); + }; + + // named handlers — enable detach after finalize to prevent listener leak + const onDataHandler = (chunk: Buffer | string): void => { + if (resolved) return; + buffer += chunk.toString(); + + // process complete lines + const lines = buffer.split('\n'); + // keep the last partial line in the buffer + buffer = lines.pop() ?? ''; + for (const line of lines) { + processLine(line); + if (resolved) return; + } + }; + + const onEndHandler = (): void => { + // process any leftover buffer + if (buffer.trim()) { + processLine(buffer); + } + if (!resolved) { + finalize(); + } + }; + + const onErrorHandler = (error: Error): void => { + if (resolved) return; + resolved = true; + detachListeners(); + onReject( + new UnexpectedCodePathError('stream error while read of brain output', { + error: error.message, + }), + ); + }; + + // wire stdout handlers + input.stdout.on('data', onDataHandler); + input.stdout.on('end', onEndHandler); + input.stdout.on('error', onErrorHandler); + }); +}; diff --git a/src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.test.ts b/src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.test.ts new file mode 100644 index 0000000..a89bceb --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.test.ts @@ -0,0 +1,112 @@ +import { BrainEpisode, BrainExchange, BrainSeries } from 'rhachet'; +import { given, then, when } from 'test-fns'; + +import { getOneAnthropicBrainCliConfig } from './BrainCli.config'; +import { getOneDispatchArgs } from './getOneDispatchArgs'; + +const config = getOneAnthropicBrainCliConfig({ + slug: 'claude@anthropic/claude/opus/v4.5', +}); + +describe('getOneDispatchArgs', () => { + given('ask mode with no prior series', () => { + when('called', () => { + then('it returns headless args with ask tools', () => { + const args = getOneDispatchArgs({ + config, + taskMode: 'ask', + series: null, + }); + expect(args).toContain('-p'); + expect(args).toContain('--model'); + const modelIndex = args.indexOf('--model'); + expect(args[modelIndex + 1]).toEqual(config.model); + expect(args).toContain('--output-format'); + expect(args).toContain('stream-json'); + expect(args).toContain('--input-format'); + expect(args).toContain('--verbose'); + expect(args).toContain('--allowedTools'); + + // ask tools should not include mutation tools + const toolsIndex = args.indexOf('--allowedTools'); + const toolsValue = args[toolsIndex + 1]!; + expect(toolsValue).toContain('Read'); + expect(toolsValue).toContain('Grep'); + expect(toolsValue).not.toContain('Edit'); + expect(toolsValue).not.toContain('Write'); + expect(toolsValue).not.toContain('Bash'); + + // no --resume + expect(args).not.toContain('--resume'); + }); + }); + }); + + given('act mode with no prior series', () => { + when('called', () => { + then('it returns headless args with act tools', () => { + const args = getOneDispatchArgs({ + config, + taskMode: 'act', + series: null, + }); + const toolsIndex = args.indexOf('--allowedTools'); + const toolsValue = args[toolsIndex + 1]!; + expect(toolsValue).toContain('Edit'); + expect(toolsValue).toContain('Write'); + expect(toolsValue).toContain('Bash'); + }); + }); + }); + + given('ask mode with a prior series', () => { + when('called', () => { + then('it includes --resume with the series exid', () => { + const series = new BrainSeries({ + hash: 'abc123', + exid: 'session-uuid-here', + episodes: [ + new BrainEpisode({ + hash: 'ep1', + exid: null, + exchanges: [ + new BrainExchange({ + hash: 'ex1', + input: 'hi', + output: 'hello', + exid: null, + }), + ], + }), + ], + }); + const args = getOneDispatchArgs({ + config, + taskMode: 'ask', + series, + }); + expect(args).toContain('--resume'); + const resumeIndex = args.indexOf('--resume'); + expect(args[resumeIndex + 1]).toEqual('session-uuid-here'); + }); + }); + }); + + given('a series with null exid', () => { + when('called', () => { + then('it does not include --resume', () => { + const series = new BrainSeries({ + hash: 'abc123', + exid: null, + episodes: [], + }); + const args = getOneDispatchArgs({ + config, + taskMode: 'ask', + series, + }); + expect(args).not.toContain('--resume'); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.ts b/src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.ts new file mode 100644 index 0000000..7279eaa --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/getOneDispatchArgs.ts @@ -0,0 +1,36 @@ +import type { BrainSeries } from 'rhachet'; + +import type { AnthropicBrainCliConfig } from './BrainCli.config'; + +/** + * .what = compute CLI args for dispatch mode boot + * .why = deterministic arg assembly — pure function, tested in isolation + */ +export const getOneDispatchArgs = (input: { + config: AnthropicBrainCliConfig; + taskMode: 'ask' | 'act'; + series: BrainSeries | null; +}): string[] => { + // base dispatch args — headless structured i/o + const args: string[] = [ + '-p', + '--model', + input.config.model, + '--output-format', + 'stream-json', + '--input-format', + 'stream-json', + '--verbose', + ]; + + // enforce tool set per task mode + const tools = input.config.tools[input.taskMode]; + args.push('--allowedTools', tools.join(',')); + + // resume prior series if extant + if (input.series?.exid) { + args.push('--resume', input.series.exid); + } + + return args; +}; diff --git a/src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.test.ts b/src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.test.ts new file mode 100644 index 0000000..bbde229 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.test.ts @@ -0,0 +1,54 @@ +import { BrainSeries } from 'rhachet'; +import { given, then, when } from 'test-fns'; + +import { getOneAnthropicBrainCliConfig } from './BrainCli.config'; +import { getOneInteractArgs } from './getOneInteractArgs'; + +const config = getOneAnthropicBrainCliConfig({ + slug: 'claude@anthropic/claude/opus/v4.5', +}); + +describe('getOneInteractArgs', () => { + given('no prior series', () => { + when('called', () => { + then('it returns args with --model', () => { + const args = getOneInteractArgs({ config, series: null }); + expect(args).toContain('--model'); + const modelIndex = args.indexOf('--model'); + expect(args[modelIndex + 1]).toEqual(config.model); + expect(args).not.toContain('--resume'); + }); + }); + }); + + given('a prior series with exid', () => { + when('called', () => { + then('it includes --resume with the series exid', () => { + const series = new BrainSeries({ + hash: 'abc123', + exid: 'session-uuid-here', + episodes: [], + }); + const args = getOneInteractArgs({ config, series }); + expect(args).toContain('--resume'); + const resumeIndex = args.indexOf('--resume'); + expect(args[resumeIndex + 1]).toEqual('session-uuid-here'); + }); + }); + }); + + given('a prior series with null exid', () => { + when('called', () => { + then('it returns args with --model but no --resume', () => { + const series = new BrainSeries({ + hash: 'abc123', + exid: null, + episodes: [], + }); + const args = getOneInteractArgs({ config, series }); + expect(args).toContain('--model'); + expect(args).not.toContain('--resume'); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.ts b/src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.ts new file mode 100644 index 0000000..8681371 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/getOneInteractArgs.ts @@ -0,0 +1,21 @@ +import type { BrainSeries } from 'rhachet'; + +import type { AnthropicBrainCliConfig } from './BrainCli.config'; + +/** + * .what = compute CLI args for interact mode boot + * .why = deterministic arg assembly — pure function, tested in isolation + */ +export const getOneInteractArgs = (input: { + config: AnthropicBrainCliConfig; + series: BrainSeries | null; +}): string[] => { + const args: string[] = ['--model', input.config.model]; + + // resume prior series if extant + if (input.series?.exid) { + args.push('--resume', input.series.exid); + } + + return args; +}; diff --git a/src/_topublish/rhachet-brains-anthropic/index.ts b/src/_topublish/rhachet-brains-anthropic/index.ts new file mode 100644 index 0000000..1047e8c --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/index.ts @@ -0,0 +1,10 @@ +export { + type BrainAuthAnthropic, + isBrainAuthAnthropic, +} from './BrainAuthAnthropic'; +export { + type AnthropicBrainCliConfig, + type AnthropicBrainCliSlug, + getOneAnthropicBrainCliConfig, +} from './BrainCli.config'; +export { genBrainCli } from './genBrainCli'; diff --git a/src/_topublish/rhachet-brains-anthropic/isBrainAuthAnthropic.test.ts b/src/_topublish/rhachet-brains-anthropic/isBrainAuthAnthropic.test.ts new file mode 100644 index 0000000..64a4b46 --- /dev/null +++ b/src/_topublish/rhachet-brains-anthropic/isBrainAuthAnthropic.test.ts @@ -0,0 +1,62 @@ +import { isBrainAuthAnthropic } from './BrainAuthAnthropic'; + +const TEST_CASES = [ + { + description: 'oauth via — returns true', + input: { via: { oauth: true } }, + expected: true, + }, + { + description: 'apiKey via — returns true', + input: { via: { apiKey: 'sk-ant-test-key' } }, + expected: true, + }, + { + description: 'both keys present — returns true', + input: { via: { oauth: true, apiKey: 'sk-ant-test-key' } }, + expected: true, + }, + { + description: 'null — returns false', + input: null, + expected: false, + }, + { + description: 'empty object — returns false', + input: {}, + expected: false, + }, + { + description: 'via is null — returns false', + input: { via: null }, + expected: false, + }, + { + description: 'via is empty object — returns false', + input: { via: {} }, + expected: false, + }, + { + description: 'non-object — returns false', + input: 'not-an-object', + expected: false, + }, + { + description: 'via with wrong oauth type — returns false', + input: { via: { oauth: 'yes' } }, + expected: false, + }, + { + description: 'via with wrong apiKey type — returns false', + input: { via: { apiKey: 123 } }, + expected: false, + }, +]; + +describe('isBrainAuthAnthropic', () => { + TEST_CASES.map((thisCase) => + test(thisCase.description, () => { + expect(isBrainAuthAnthropic(thisCase.input)).toEqual(thisCase.expected); + }), + ); +}); diff --git a/src/_topublish/rhachet/BrainCli.ts b/src/_topublish/rhachet/BrainCli.ts new file mode 100644 index 0000000..cc6fe3f --- /dev/null +++ b/src/_topublish/rhachet/BrainCli.ts @@ -0,0 +1,35 @@ +import type { BrainOutput, BrainSeries } from 'rhachet'; + +/** + * .what = the mode a brain CLI process can be booted in + * .why = dispatch for structured headless i/o, interact for raw PTY relay + */ +export type BrainCliMode = 'dispatch' | 'interact'; + +/** + * .what = the handle contract for a headless brain CLI process + * .why = stable ref that survives process churn — the daemon codes against this shape + */ +export interface BrainCli { + // dispatch methods — structured task submission + ask(input: { prompt: string }): Promise>; + act(input: { prompt: string }): Promise>; + + // durable state — survives process reboots + memory: { series: BrainSeries | null }; + + // process lifecycle — ephemeral, per-boot + executor: { + instance: { pid: number; mode: BrainCliMode } | null; + boot(input: { mode: BrainCliMode }): Promise; + kill(): void; + }; + + // raw i/o surface — works in both modes + terminal: { + write(data: string): void; + resize(input: { cols: number; rows: number }): void; + onData(cb: (chunk: string) => void): void; + onExit(cb: (info: { code: number; signal: string | null }) => void): void; + }; +} diff --git a/src/_topublish/rhachet/ContextBrainAuth.ts b/src/_topublish/rhachet/ContextBrainAuth.ts new file mode 100644 index 0000000..0d91359 --- /dev/null +++ b/src/_topublish/rhachet/ContextBrainAuth.ts @@ -0,0 +1,15 @@ +/** + * .what = generic auth context for brain CLI processes + * .why = each supplier defines its own auth shape keyed by supplier name; + * callers can narrow the generic for compile-time safety + * + * .note = nested key context.brain.auth. (e.g., .anthropic, .openai) + * namespaces auth per supplier — can disambiguate later if needed + */ +export type ContextBrainAuth< + TBrainAuthSupply extends Record = Record, +> = { + brain?: { + auth?: TBrainAuthSupply; + }; +}; diff --git a/src/_topublish/rhachet/ContextBrainAuth.type.test.ts b/src/_topublish/rhachet/ContextBrainAuth.type.test.ts new file mode 100644 index 0000000..459cbd1 --- /dev/null +++ b/src/_topublish/rhachet/ContextBrainAuth.type.test.ts @@ -0,0 +1,80 @@ +import { given, then, when } from 'test-fns'; + +import type { ContextBrainAuth } from './ContextBrainAuth'; + +/** + * .what = compile-time type assertions for ContextBrainAuth + * .why = proves the generic auth context type accepts valid shapes and rejects invalid ones + */ +describe('ContextBrainAuth', () => { + given('[case1] valid shapes', () => { + when('[t0] positive: all optional keys omitted', () => { + then('it compiles', () => { + const ctx: ContextBrainAuth = {}; + expect(ctx).toBeDefined(); + }); + }); + + when('[t1] positive: brain key present but empty', () => { + then('it compiles', () => { + const ctx: ContextBrainAuth = { brain: {} }; + expect(ctx).toBeDefined(); + }); + }); + + when('[t2] positive: brain.auth present but empty', () => { + then('it compiles', () => { + const ctx: ContextBrainAuth = { brain: { auth: {} } }; + expect(ctx).toBeDefined(); + }); + }); + + when('[t3] positive: narrowed with supplier key', () => { + then('it compiles', () => { + const ctx: ContextBrainAuth<{ + anthropic: { via: { apiKey: string } }; + }> = { + brain: { auth: { anthropic: { via: { apiKey: 'sk-test' } } } }, + }; + expect(ctx).toBeDefined(); + }); + }); + + when('[t4] positive: narrowed supplier key with brain omitted', () => { + then('it compiles (brain is optional)', () => { + const ctx: ContextBrainAuth<{ + anthropic: { via: { oauth: true } }; + }> = {}; + expect(ctx).toBeDefined(); + }); + }); + }); + + given('[case2] invalid shapes', () => { + when('[t0] negative: narrowed generic rejects wrong supplier shape', () => { + then('it fails to compile', () => { + type Narrowed = ContextBrainAuth<{ + anthropic: { via: { apiKey: string } }; + }>; + const _ctx: Narrowed = { + // @ts-expect-error — wrong shape: 'bad' is not { via: { apiKey: string } } + brain: { auth: { anthropic: { bad: 'shape' } } }, + }; + }); + }); + + when('[t1] negative: brain must be an object if present', () => { + then('it fails to compile', () => { + // @ts-expect-error — brain must be { auth?: ... }, not a string + const _ctx: ContextBrainAuth = { brain: 'not-an-object' }; + }); + }); + + when('[t2] negative: auth must be a record if present', () => { + then('it fails to compile', () => { + // @ts-expect-error — auth must be Record, not a string + const _ctx: ContextBrainAuth = { brain: { auth: 'not-a-record' } }; + }); + }); + }); +}); diff --git a/src/_topublish/rhachet/genBrainCli.ts b/src/_topublish/rhachet/genBrainCli.ts new file mode 100644 index 0000000..e72dfd2 --- /dev/null +++ b/src/_topublish/rhachet/genBrainCli.ts @@ -0,0 +1,38 @@ +import { BadRequestError } from 'helpful-errors'; + +import type { BrainCli } from './BrainCli'; +import type { ContextBrainAuth } from './ContextBrainAuth'; +import { getOneSupplierSlugFromBrainSlug } from './getOneSupplierSlugFromBrainSlug'; + +/** + * .what = route a brain slug to the correct supplier and return a BrainCli handle + * .why = dependency inversion — khlone never touches vendor CLI args + */ +export const genBrainCli = async < + TBrainAuthSupply extends Record = Record, +>( + input: { slug: string }, + context: { + cwd: string; + env?: Record; + } & ContextBrainAuth, +): Promise => { + // extract supplier prefix from slug + const supplierSlug = getOneSupplierSlugFromBrainSlug({ + slug: input.slug, + }); + + // route to supplier + if (supplierSlug === 'anthropic') { + const { genBrainCli: genAnthropicBrainCli } = await import( + '../rhachet-brains-anthropic/genBrainCli' + ); + return genAnthropicBrainCli(input, context); + } + + // fail fast for unsupported suppliers + throw new BadRequestError( + `unsupported brain supplier: '${supplierSlug}' (from slug '${input.slug}')`, + { slug: input.slug, supplierSlug }, + ); +}; diff --git a/src/_topublish/rhachet/getOneSupplierSlugFromBrainSlug.test.ts b/src/_topublish/rhachet/getOneSupplierSlugFromBrainSlug.test.ts new file mode 100644 index 0000000..5b566ce --- /dev/null +++ b/src/_topublish/rhachet/getOneSupplierSlugFromBrainSlug.test.ts @@ -0,0 +1,66 @@ +import { BadRequestError } from 'helpful-errors'; +import { getError, given, then, when } from 'test-fns'; + +import { getOneSupplierSlugFromBrainSlug } from './getOneSupplierSlugFromBrainSlug'; + +const TEST_CASES = [ + { + description: 'extracts anthropic from a claude brain slug', + given: { slug: 'claude@anthropic/claude/opus/v4.5' }, + expect: { output: 'anthropic' }, + }, + { + description: 'extracts anthropic from a sonnet slug', + given: { slug: 'claude@anthropic/claude/sonnet/v4.5' }, + expect: { output: 'anthropic' }, + }, + { + description: 'extracts xai from a grok slug', + given: { slug: 'grok@xai/grok/v1' }, + expect: { output: 'xai' }, + }, + { + description: 'extracts opencode from an opencode slug', + given: { slug: 'opencode@opencode/opencode/v1' }, + expect: { output: 'opencode' }, + }, +]; + +describe('getOneSupplierSlugFromBrainSlug', () => { + given('valid brain slugs', () => { + TEST_CASES.map((thisCase) => + when(thisCase.description, () => { + then('it returns the correct supplier slug', () => { + const result = getOneSupplierSlugFromBrainSlug({ + slug: thisCase.given.slug, + }); + expect(result).toEqual(thisCase.expect.output); + }); + }), + ); + }); + + given('a slug with no @ separator', () => { + when('called', () => { + then('it throws a BadRequestError', async () => { + const error = await getError(() => + getOneSupplierSlugFromBrainSlug({ slug: 'invalid-slug' }), + ); + expect(error).toBeInstanceOf(BadRequestError); + expect((error as Error).message).toContain('no @ separator'); + }); + }); + }); + + given('a slug with no / after supplier', () => { + when('called', () => { + then('it throws a BadRequestError', async () => { + const error = await getError(() => + getOneSupplierSlugFromBrainSlug({ slug: 'claude@anthropic' }), + ); + expect(error).toBeInstanceOf(BadRequestError); + expect((error as Error).message).toContain('no / after supplier'); + }); + }); + }); +}); diff --git a/src/_topublish/rhachet/getOneSupplierSlugFromBrainSlug.ts b/src/_topublish/rhachet/getOneSupplierSlugFromBrainSlug.ts new file mode 100644 index 0000000..608b701 --- /dev/null +++ b/src/_topublish/rhachet/getOneSupplierSlugFromBrainSlug.ts @@ -0,0 +1,29 @@ +import { BadRequestError } from 'helpful-errors'; + +/** + * .what = extract the supplier slug from a brain slug + * .why = the contract factory routes to the correct supplier without knowledge of slug internals + * + * .note = slug format: '@/' + * .note = e.g., 'claude@anthropic/claude/opus/v4.5' returns 'anthropic' + */ +export const getOneSupplierSlugFromBrainSlug = (input: { + slug: string; +}): string => { + // find the @ separator + const atIndex = input.slug.indexOf('@'); + if (atIndex === -1) + BadRequestError.throw('invalid brain slug: no @ separator', { + slug: input.slug, + }); + + // extract the supplier prefix between @ and first / + const afterAt = input.slug.slice(atIndex + 1); + const slashIndex = afterAt.indexOf('/'); + if (slashIndex === -1) + BadRequestError.throw('invalid brain slug: no / after supplier', { + slug: input.slug, + }); + + return afterAt.slice(0, slashIndex); +}; diff --git a/src/_topublish/rhachet/index.ts b/src/_topublish/rhachet/index.ts new file mode 100644 index 0000000..0c8f82b --- /dev/null +++ b/src/_topublish/rhachet/index.ts @@ -0,0 +1,3 @@ +export type { BrainCli, BrainCliMode } from './BrainCli'; +export type { ContextBrainAuth } from './ContextBrainAuth'; +export { genBrainCli } from './genBrainCli';