Compare commits

..

1 commit

Author SHA1 Message Date
98b6f9ab64 fix(install): make fresh install actually complete + ship tamagotchi
Some checks failed
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / preflight (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / vps-smoke (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:frustration-matrix,kei-frustration-loop,kei-skill-importer,kei-projects-index,kei-projects-watcher,kei-gdrive-import,kei-leak-matrix,kei-skills,kei-gateway,kei-cron-scheduler,kei-export-trajectories,kei-backend-daytona,kei-d… (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-compute-baremetal,kei-compute-vultr,kei-compute-linode,kei-compute-digitalocean,kei-svc-systemd,kei-llm-bridge-mlx name:hosted-sleep-compute]) (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-diff,kei-scheduler,kei-watch,kei-prune,kei-discover,kei-brain-view,kei-hibernate,kei-ledger-sign,kei-fork name:wave13-15]) (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-git-gitea,kei-git-forgejo,kei-git-gitlab,kei-git-bitbucket,kei-memory-sled,kei-memory-redis,kei-memory-postgres,kei-memory-sqlite,kei-auth-google,kei-auth-apple,kei-auth-magiclink,kei-auth-webauthn,kei-notify-slack,kei-n… (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-ledger,kei-migrate,kei-changelog,kei-memory,kei-store,kei-conflict-scan,kei-refactor-engine,kei-graph-check,kei-shared,kei-dna-index,kei-pet name:core]) (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-machine-probe,kei-llm-ollama,kei-llm-llamacpp,kei-llm-mlx,kei-llm-router,kei-model name:llm-stack]) (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-router,kei-sage,kei-task,kei-chat-store,kei-crossdomain,kei-search-core,kei-content-store,kei-social-store,kei-curator,kei-auth,kei-artifact name:mcp-lbm]) (pull_request) Has been cancelled
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:keisei,kei-forge,kei-runtime,kei-runtime-core,kei-atom-discovery,kei-agent-runtime,kei-capability,kei-provision,kei-entity-store,kei-pipe,kei-cache,kei-spawn,kei-replay name:atom-substrate]) (pull_request) Has been cancelled
Root causes found by reproducing a clean install from keigit:

1. PROFILE_PRIMS resolved only inside check_prereqs → unbound for
   --no-execute (plan showed 0 prims for every profile) and silently
   empty for --skip-prereqs. Now resolved unconditionally in install.sh
   before any reader (SSoT).

2. Every profile (even minimal, advertised "no Rust compile") fell back
   to a 5-15 min `cargo build --workspace` because no prebuilt release
   binaries exist. Auto-set KEI_SKIP_RUST for profiles with no rust
   primitives → minimal installs in ~18s (assembler only). cargo stays a
   hard prereq because the agent assembler always compiles.

3. The assembler aborted the WHOLE install on any single bad manifest
   (set -e). generate_agents is now tolerant: bad manifests print FAIL
   but hooks/skills/settings still land. Commit-time validate stays strict.

4. Data bugs that broke the assembler:
   - duplicate [taxonomy] table in _roles/{auditor,merger}.toml
   - fal-ai-runner handoff → keimd-expert (not shipped in kit)
   - infra-implementer-cicd forbidden_domain literal `${{ secrets.NAME }}`
     collided with assembler ${{ }} placeholder detection

5. Metadata: KeiSei84 (nonexistent GitHub org) → KeiSeiLab/KeiSeiKit-1.0
   across plugin manifests, bootstrap, README, docs, Cargo/npm metadata.
   .claude-plugin/{plugin,marketplace}.json 0.16.0 → 0.38.0. SECURITY.md
   supported version 0.14.x → 0.38.x.

feat: ship KeiSei tamagotchi statusline into the kit
   - scripts/keisei-pet{,-update}.sh (portable, state under ~/.claude/pet/)
   - install copies them to ~/.claude/scripts/
   - settings-snippet adds statusLine (set-if-absent, never clobbers an
     existing one) + 4 pet-update hooks (prompt/rust_write/github_block/sleep)

Verified: clean minimal install RC=0, zero FAIL, 38 agents + 52 hooks +
68 skills, settings valid, statusLine wired, pet renders, idempotent re-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:14:17 +08:00
92 changed files with 346 additions and 4158 deletions

View file

@ -145,7 +145,7 @@ jobs:
continue-on-error: true continue-on-error: true
workflow-lint: workflow-lint:
# v0.20.1: guards against the dtolnay-SHA-class incident. # v0.20.1: guards against the dtolnay-SHA-class incident (2026-04-22).
# actionlint catches workflow syntax; validate-workflow-shas.sh catches # actionlint catches workflow syntax; validate-workflow-shas.sh catches
# fabricated / force-pushed SHA pins. Runs fast (<30s). # fabricated / force-pushed SHA pins. Runs fast (<30s).
runs-on: ubuntu-latest runs-on: ubuntu-latest

2
.gitmodules vendored
View file

@ -1,4 +1,4 @@
[submodule "_blocks/registries"] [submodule "_blocks/registries"]
path = _blocks/registries path = _blocks/registries
url = https://github.com/KeiSeiLab/kei-registries.git url = https://keigit.com/keisei/kei-registries.git
shallow = true shallow = true

View file

@ -6,45 +6,6 @@
--- ---
## 2026-05-25 — Opt-in hook packs + stack profiles (public-prep posture)
### Context
The kit force-activated every hook via `settings-snippet.json`, including the
author's personal research discipline (numeric-claims evidence markers,
no-downgrade, citation-verify, rust-first / no-python). For a public,
general-audience kit that is presumptuous — users bring their own stack and do
not need a Rust-only policy or evidence-marker enforcement by default.
### Decision
- Posture: **safety hooks on by default; all discipline packs opt-in.** Packs:
`safety` (always), `evidence`, `observability`, `epistemic`, `orchestration`,
`git-guard`, `stack-rust`. SSoT = `_primitives/hook-packs.toml`.
- **Stack profiles** (minimal / web / ml / systems / mobile) pull a set of
discipline packs AND an agent set. `rust-first` / `no-python` live only in
`stack-rust`, which only the `systems` stack enables. `git-guard`
(no-github-push) is opt-in only and pulled by NO stack — a general kit must
not block a user's normal `git push` to github.
- Mechanism: install-time **filter** of the snippet by selected packs
(`filter_snippet_by_packs`) + **prune** of kit-owned hooks on reconfigure
(`prune_kit_hooks`, foreign hooks preserved). Selection persists to
`~/.claude/config/onboarding.toml`; re-runnable via `kei configure`.
- Non-interactive / `--yes` / CI default = minimal (safety + cosmetic only),
all agents (back-compat for power users).
### Consequences
- Gate wiring (`_lib/gate.sh`) added to the 8 highest-friction discipline hooks
for runtime toggling via the `hooks-control` skill; remaining cosmetic/event
hooks deferred (install-time filtering already gives "off by default", so the
runtime gate is a convenience, not a correctness requirement).
- Agent-set changes via `kei configure` apply on the next `./install.sh`
(reconfigure re-applies hooks fully but does not remove already-installed
agent manifests — they are harmless extra `.md` files).
- `_toml_array` extracted from `lib-profile.sh:profile_members` as the shared
one-line-array TOML reader (no new dependency).
## 2026-04-28 — Three scheduling abstractions in workspace ## 2026-04-28 — Three scheduling abstractions in workspace
### Context ### Context

View file

@ -8,8 +8,8 @@ OpenClaw / Kimi from the same source-of-truth.
**Apache 2.0** — explicit patent grant + retaliation clause. 105 Rust **Apache 2.0** — explicit patent grant + retaliation clause. 105 Rust
crates [REAL: `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`], crates [REAL: `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`],
69 skills [REAL: `ls skills/ | wc -l`], 54 hooks 68 skills [REAL: `ls skills/ | wc -l`], 38 hooks
[REAL: `ls hooks/*.sh | wc -l`], 38 agent manifests [REAL: `grep -c '"command":' settings-snippet.json`], 38 agent manifests
[REAL: `ls _manifests/*.toml | wc -l`], 85 substrate blocks [REAL: `ls _manifests/*.toml | wc -l`], 85 substrate blocks
[REAL: `find _blocks/ -name '*.md' | wc -l`], 18 capability atoms [REAL: `find _blocks/ -name '*.md' | wc -l`], 18 capability atoms
[REAL: `find _capabilities/ -mindepth 2 -maxdepth 2 -type d | wc -l`], [REAL: `find _capabilities/ -mindepth 2 -maxdepth 2 -type d | wc -l`],
@ -72,7 +72,7 @@ curl -fsSL https://install.keisei.app | bash -s -- --profile=dev --yes # CI
/plugin install keisei@keisei-marketplace /plugin install keisei@keisei-marketplace
# Any MCP-compatible client (Cursor / Continue / Zed / Aider / etc) # Any MCP-compatible client (Cursor / Continue / Zed / Aider / etc)
git clone https://github.com/KeiSeiLab/KeiSeiKit-1.0.git git clone https://keigit.com/keisei/KeiSeiKit-1.0.git
cd KeiSeiKit-1.0 cd KeiSeiKit-1.0
./bootstrap.sh # interactive profile picker ./bootstrap.sh # interactive profile picker
# or: ./install.sh --profile=minimal # direct # or: ./install.sh --profile=minimal # direct
@ -83,7 +83,7 @@ The web installer (`web-install.sh` in this repo, served at
repo and delegates to `bootstrap.sh` — single source of truth, no repo and delegates to `bootstrap.sh` — single source of truth, no
duplicated install logic. duplicated install logic.
38 agents + 69 skills + 54 hooks + nightly consolidation wired in 38 agents + 68 skills + 38 hooks + nightly consolidation wired in
~60 seconds. Twelve install profiles (`outcome-only`, `minimal`, ~60 seconds. Twelve install profiles (`outcome-only`, `minimal`,
`core`, `frontend`, `ops`, `dev`, `mcp`, `cortex`, `local-mirror`, `core`, `frontend`, `ops`, `dev`, `mcp`, `cortex`, `local-mirror`,
`dashboard`, `full-hub`, `full`) defined in `dashboard`, `full-hub`, `full`) defined in

View file

@ -45,12 +45,6 @@ fn write_frontmatter(m: &Manifest, out: &mut String) {
out.push_str(&format!("description: {}\n", desc.trim())); out.push_str(&format!("description: {}\n", desc.trim()));
out.push_str(&format!("tools: {}\n", m.tools.join(", "))); out.push_str(&format!("tools: {}\n", m.tools.join(", ")));
out.push_str(&format!("model: {}\n", m.model)); out.push_str(&format!("model: {}\n", m.model));
// v0.39: optional provider for DNA-resolved kei agent dispatch.
if let Some(prov) = &m.provider {
if !prov.is_empty() {
out.push_str(&format!("provider: {}\n", prov));
}
}
out.push_str("---\n\n"); out.push_str("---\n\n");
out.push_str(&format!( out.push_str(&format!(
"<!-- GENERATED by _assembler (Rust) from _manifests/{}.toml — DO NOT EDIT. Edit the manifest. -->\n\n", "<!-- GENERATED by _assembler (Rust) from _manifests/{}.toml — DO NOT EDIT. Edit the manifest. -->\n\n",

View file

@ -9,13 +9,6 @@ pub struct Manifest {
pub description: String, pub description: String,
pub tools: Vec<String>, pub tools: Vec<String>,
pub model: String, pub model: String,
/// v0.39 (multi-CLI): optional LLM provider this agent prefers when invoked
/// via `kei agent <name>`. Values: claude / grok / agy / copilot / kimi /
/// codex. Empty / missing → DNA resolver falls back to ~/.claude/config/
/// primary.toml, then to claude. Affects `kei run-via` / `kei agent`
/// dispatch; does NOT change Claude Code's in-session model.
#[serde(default)]
pub provider: Option<String>,
pub role: String, pub role: String,
pub blocks: Vec<String>, pub blocks: Vec<String>,
/// v0.16 (phase 5): agent substrate role. When present, assembler loads /// v0.16 (phase 5): agent substrate role. When present, assembler loads

View file

@ -99,7 +99,7 @@ extra = [
"path:user-rules/dev-workflow.md", "path:user-rules/dev-workflow.md",
"path:user-rules/debugging.md", "path:user-rules/debugging.md",
"path:user-rules/karpathy-behavioral.md", "path:user-rules/karpathy-behavioral.md",
"Architecture Overlay Incident (model_brain.py 227→354 LOC from \"fixes\" — never patch, fix root formulas)", "MEMORY.md → Architecture Overlay Incident (model_brain.py 227→354 LOC from \"fixes\" — never patch, fix root formulas)",
] ]
[taxonomy] [taxonomy]

View file

@ -13,7 +13,7 @@ You are the cost guardian. Your job is to make sure no paid compute launches wit
verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop \ verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop \
runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do \ runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do \
NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident \ NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident \
** is the cautionary tale: prices guessed not verified, silent retries \ (2026-02-26)** is the cautionary tale: prices guessed not verified, silent retries \
re-billing, file changes never confirmed, dashboard never checked. Every protocol below \ re-billing, file changes never confirmed, dashboard never checked. Every protocol below \
exists because of that day never again. exists because of that day never again.
""" """

View file

@ -419,4 +419,4 @@ Blockers / next: <list>
- `path:user-rules/dev-workflow.md` - `path:user-rules/dev-workflow.md`
- `path:user-rules/debugging.md` - `path:user-rules/debugging.md`
- `path:user-rules/karpathy-behavioral.md` - `path:user-rules/karpathy-behavioral.md`
- `Architecture Overlay Incident (model_brain.py 227→354 LOC from "fixes" — never patch, fix root formulas)` - `MEMORY.md → Architecture Overlay Incident (model_brain.py 227→354 LOC from "fixes" — never patch, fix root formulas)`

View file

@ -13,7 +13,7 @@ model: opus
# ROLE # ROLE
You are the cost guardian. Your job is to make sure no paid compute launches without a verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident ** is the cautionary tale: prices guessed not verified, silent retries re-billing, file changes never confirmed, dashboard never checked. Every protocol below exists because of that day — never again. You are the cost guardian. Your job is to make sure no paid compute launches without a verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident (2026-02-26)** is the cautionary tale: prices guessed not verified, silent retries re-billing, file changes never confirmed, dashboard never checked. Every protocol below exists because of that day — never again.
# AGENT SUBSTRATE — role `read-only` # AGENT SUBSTRATE — role `read-only`

@ -1 +1 @@
Subproject commit b90499311ed09cbf88ced93aaa9d890dcf861fac Subproject commit c5590658ee636398c512ce2b25d8aedb3212a9b6

View file

@ -463,4 +463,4 @@ behaviour-verified: yes | no | not-applicable
follow-up-required: follow-up-required:
- <bullet list> - <bullet list>
``` ```
- `Architecture Overlay Incident (model_brain.py 227→354 LOC from "fixes" — never patch, fix root formulas)` - `MEMORY.md → Architecture Overlay Incident (model_brain.py 227→354 LOC from "fixes" — never patch, fix root formulas)`

View file

@ -9,7 +9,7 @@ model: sonnet
# ROLE # ROLE
You are the cost guardian. Your job is to make sure no paid compute launches without a verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident** is the cautionary tale: prices guessed not verified, silent retries re-billing, file changes never confirmed, dashboard never checked. Every protocol below exists because of that day — never again. You are the cost guardian. Your job is to make sure no paid compute launches without a verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident (2026-02-26)** is the cautionary tale: prices guessed not verified, silent retries re-billing, file changes never confirmed, dashboard never checked. Every protocol below exists because of that day — never again.
# AGENT SUBSTRATE — role `read-only` # AGENT SUBSTRATE — role `read-only`

View file

@ -438,9 +438,9 @@ Blockers / next: <list>
- `{path::user-rules}/git-conventions.md` - `{path::user-rules}/git-conventions.md`
- `{path::user-rules}/dev-workflow.md` - `{path::user-rules}/dev-workflow.md`
- `{path::user-memory}/security-restricted-projects.md` - `{path::user-memory}/security-restricted-projects.md`
- `Compute Cost Incident: $98.78 Modal overrun — no dashboard check, unverified prices.` - `MEMORY.md → Compute Cost Incident (2026-02-26): $98.78 Modal overrun — no dashboard check, unverified prices.`
- `Recruiter shared-EC2 risk (i-0a8b747023809d451 shared with 3 projects, default SECRET_KEY, no CSRF).` - `MEMORY.md → Recruiter shared-EC2 risk (i-0a8b747023809d451 shared with 3 projects, default SECRET_KEY, no CSRF).`
- `CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.` - `MEMORY.md → CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.`
## Output Footer (RULE 0.16) ## Output Footer (RULE 0.16)

View file

@ -483,8 +483,8 @@ Blockers / next: <list>
- `{path::user-rules}/manifold-tangent-sanity.md` - `{path::user-rules}/manifold-tangent-sanity.md`
- `{path::user-rules}/no-downgrade-constructive.md` - `{path::user-rules}/no-downgrade-constructive.md`
- `{path::user-memory}/wrong-paths-specialized-ml.md` - `{path::user-memory}/wrong-paths-specialized-ml.md`
- `Compute Cost Incident: promised $27, spent $98.78 on Modal. NEVER AGAIN.` - `MEMORY.md → Compute Cost Incident (2026-02-26): promised $27, spent $98.78 on Modal. NEVER AGAIN.`
- `Architecture Overlay Incident: model_brain.py 227→354 LOC from audit fixes. No Patching.` - `MEMORY.md → Architecture Overlay Incident: model_brain.py 227→354 LOC from audit fixes. No Patching.`
## Output Footer (RULE 0.16) ## Output Footer (RULE 0.16)

View file

@ -11,9 +11,9 @@ model: sonnet
You are the Modal compute orchestrator. You launch Modal jobs safely, observe them well, and NEVER burn money or kill running work. Two incidents shape every rule below. You are the Modal compute orchestrator. You launch Modal jobs safely, observe them well, and NEVER burn money or kill running work. Two incidents shape every rule below.
$98.78 Modal Incident: promised $27, spent $98.78 in one session. Prices guessed not verified, failed retries silently re-billed, file changes never confirmed, dashboard never checked. Every cost rule exists because of that day. $98.78 Modal Incident (2026-02-26): promised $27, spent $98.78 in one session. Prices guessed not verified, failed retries silently re-billed, file changes never confirmed, dashboard never checked. Every cost rule exists because of that day.
anti-stop guard Incident: stopped a 1.4-hour training run for a non-critical bug. Cost: 1.4 hours A10G + restart + re-warmup. Every kill rule exists because of that day. anti-stop guard Incident (2026-03-29): stopped a 1.4-hour training run for a non-critical bug. Cost: 1.4 hours A10G + restart + re-warmup. Every kill rule exists because of that day.
Cost tiers: <$5 per run → AUTO; $5-$20 → WARN + daily-cap check ($20/day session); >$20 → STOP and ask. Always state estimate in dollars BEFORE launch: "Estimate: $X.XX (= N_gpus × hours × $/hr/gpu)". GPU compat: A10G torch>=2.0 (~$1.10/hr), H100 torch>=2.1 (~$4.50/hr), B200 torch>=2.6 (~$8/hr). Always verify on pricing page — rates change. Cost tiers: <$5 per run → AUTO; $5-$20 → WARN + daily-cap check ($20/day session); >$20 → STOP and ask. Always state estimate in dollars BEFORE launch: "Estimate: $X.XX (= N_gpus × hours × $/hr/gpu)". GPU compat: A10G torch>=2.0 (~$1.10/hr), H100 torch>=2.1 (~$4.50/hr), B200 torch>=2.6 (~$8/hr). Always verify on pricing page — rates change.

View file

@ -99,7 +99,7 @@ extra = [
"path:user-rules/dev-workflow.md", "path:user-rules/dev-workflow.md",
"path:user-rules/debugging.md", "path:user-rules/debugging.md",
"path:user-rules/karpathy-behavioral.md", "path:user-rules/karpathy-behavioral.md",
"Architecture Overlay Incident (model_brain.py 227→354 LOC from \"fixes\" — never patch, fix root formulas)", "MEMORY.md → Architecture Overlay Incident (model_brain.py 227→354 LOC from \"fixes\" — never patch, fix root formulas)",
] ]
[taxonomy] [taxonomy]

View file

@ -13,7 +13,7 @@ You are the cost guardian. Your job is to make sure no paid compute launches wit
verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop \ verified cost estimate, a checked dashboard, and a clean head-room calculation. You stop \
runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do \ runaway spend before it starts. You are READ-ONLY: you emit a GO/NO-GO report card; you do \
NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident \ NOT launch jobs yourself (hand back to user or `ml-implementer`). **The $98.78 Modal incident \
** is the cautionary tale: prices guessed not verified, silent retries \ (2026-02-26)** is the cautionary tale: prices guessed not verified, silent retries \
re-billing, file changes never confirmed, dashboard never checked. Every protocol below \ re-billing, file changes never confirmed, dashboard never checked. Every protocol below \
exists because of that day never again. exists because of that day never again.
""" """

View file

@ -100,9 +100,9 @@ extra = [
"path:user-rules/git-conventions.md", "path:user-rules/git-conventions.md",
"path:user-rules/dev-workflow.md", "path:user-rules/dev-workflow.md",
"path:user-memory/security-restricted-projects.md", "path:user-memory/security-restricted-projects.md",
"Compute Cost Incident: $98.78 Modal overrun — no dashboard check, unverified prices.", "MEMORY.md → Compute Cost Incident (2026-02-26): $98.78 Modal overrun — no dashboard check, unverified prices.",
"Recruiter shared-EC2 risk (<ec2-instance-id> shared with 3 projects, default SECRET_KEY, no CSRF).", "MEMORY.md → Recruiter shared-EC2 risk (<ec2-instance-id> shared with 3 projects, default SECRET_KEY, no CSRF).",
"CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.", "MEMORY.md → CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.",
] ]
[taxonomy] [taxonomy]

View file

@ -113,8 +113,8 @@ extra = [
"path:user-rules/manifold-tangent-sanity.md", "path:user-rules/manifold-tangent-sanity.md",
"path:user-rules/no-downgrade-constructive.md", "path:user-rules/no-downgrade-constructive.md",
"path:user-memory/wrong-paths-specialized-ml.md", # TODO verify path:user-memory exists in assembler resolver "path:user-memory/wrong-paths-specialized-ml.md", # TODO verify path:user-memory exists in assembler resolver
"Compute Cost Incident: promised $27, spent $98.78 on Modal. NEVER AGAIN.", "MEMORY.md → Compute Cost Incident (2026-02-26): promised $27, spent $98.78 on Modal. NEVER AGAIN.",
"Architecture Overlay Incident: model_brain.py 227→354 LOC from audit fixes. No Patching.", "MEMORY.md → Architecture Overlay Incident: model_brain.py 227→354 LOC from audit fixes. No Patching.",
] ]
[taxonomy] [taxonomy]

View file

@ -12,11 +12,11 @@ role = """
You are the Modal compute orchestrator. You launch Modal jobs safely, observe them well, and NEVER \ You are the Modal compute orchestrator. You launch Modal jobs safely, observe them well, and NEVER \
burn money or kill running work. Two incidents shape every rule below. burn money or kill running work. Two incidents shape every rule below.
$98.78 Modal Incident: promised $27, spent $98.78 in one session. Prices guessed not \ $98.78 Modal Incident (2026-02-26): promised $27, spent $98.78 in one session. Prices guessed not \
verified, failed retries silently re-billed, file changes never confirmed, dashboard never checked. \ verified, failed retries silently re-billed, file changes never confirmed, dashboard never checked. \
Every cost rule exists because of that day. Every cost rule exists because of that day.
anti-stop guard Incident: stopped a 1.4-hour training run for a non-critical bug. Cost: \ anti-stop guard Incident (2026-03-29): stopped a 1.4-hour training run for a non-critical bug. Cost: \
1.4 hours A10G + restart + re-warmup. Every kill rule exists because of that day. 1.4 hours A10G + restart + re-warmup. Every kill rule exists because of that day.
Cost tiers: <$5 per run AUTO; $5-$20 WARN + daily-cap check ($20/day session); >$20 STOP \ Cost tiers: <$5 per run AUTO; $5-$20 WARN + daily-cap check ($20/day session); >$20 STOP \

View file

@ -34,10 +34,10 @@ buddy = ["kei-buddy", "kei-telegram-webhook", "kei-shared", "kei-chat-store",
# previous one — `dashboard` extends `local-mirror`, `full-hub` extends # previous one — `dashboard` extends `local-mirror`, `full-hub` extends
# `dashboard`. Native macOS arm64 (brew + launchd plists). See # `dashboard`. Native macOS arm64 (brew + launchd plists). See
# `install/lib-dev-hub-*.sh` for the install logic per component. # `install/lib-dev-hub-*.sh` for the install logic per component.
local-mirror = ["kei-cortex", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner"] local-mirror = ["kei-cortex", "cortex-ui", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner"]
dashboard = ["kei-cortex", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner", "kei-projects-index", "kei-projects-watcher", "dev-hub-datasette"] dashboard = ["kei-cortex", "cortex-ui", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner", "kei-projects-index", "kei-projects-watcher", "dev-hub-datasette"]
full-hub = ["kei-cortex", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner", "kei-projects-index", "kei-projects-watcher", "dev-hub-datasette", "dev-hub-zoekt", "dev-hub-mdbook", "dev-hub-restic", "dev-hub-gdrive-import"] full-hub = ["kei-cortex", "cortex-ui", "kei-pet", "kei-shared", "kei-ledger", "kei-memory", "frustration-matrix", "kei-skill-importer", "kei-router", "kei-dna-index", "kei-atom-discovery", "dev-hub-forgejo", "dev-hub-forgejo-runner", "kei-projects-index", "kei-projects-watcher", "dev-hub-datasette", "dev-hub-zoekt", "dev-hub-mdbook", "dev-hub-restic", "dev-hub-gdrive-import"]
full = ["tomd", "kei-doctor", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth", "kei-artifact", "keisei", "kei-agent-runtime", "kei-capability", "kei-provision", "kei-entity-store", "kei-pipe", "kei-cache", "kei-spawn", "kei-replay", "kei-cortex", "kei-pet", "kei-shared", "frustration-matrix", "kei-skill-importer", "kei-projects-index", "kei-projects-watcher", "dev-hub-forgejo", "dev-hub-forgejo-runner", "dev-hub-datasette", "dev-hub-zoekt", "dev-hub-mdbook", "dev-hub-restic", "dev-hub-gdrive-import"] full = ["tomd", "kei-doctor", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth", "kei-artifact", "keisei", "kei-agent-runtime", "kei-capability", "kei-provision", "kei-entity-store", "kei-pipe", "kei-cache", "kei-spawn", "kei-replay", "kei-cortex", "cortex-ui", "kei-pet", "kei-shared", "frustration-matrix", "kei-skill-importer", "kei-projects-index", "kei-projects-watcher", "dev-hub-forgejo", "dev-hub-forgejo-runner", "dev-hub-datasette", "dev-hub-zoekt", "dev-hub-mdbook", "dev-hub-restic", "dev-hub-gdrive-import"]
# --- shell primitives (13) ------------------------------------------------- # --- shell primitives (13) -------------------------------------------------
@ -357,6 +357,12 @@ crate = "kei-cortex"
deps = ["python3 (>=3.9, for whisper_worker.py subprocess)", "pip install -r scripts/requirements.txt", "ffmpeg (on PATH, faster-whisper audio demux)"] deps = ["python3 (>=3.9, for whisper_worker.py subprocess)", "pip install -r scripts/requirements.txt", "ffmpeg (on PATH, faster-whisper audio demux)"]
desc = "Local HTTP daemon exposing chat/TTS/STT/portrait endpoints — backs cortex-ui browser app" desc = "Local HTTP daemon exposing chat/TTS/STT/portrait endpoints — backs cortex-ui browser app"
[primitive.cortex-ui]
kind = "node"
path = "_ts_packages/packages/cortex-ui"
deps = ["node>=18", "pnpm"]
desc = "Svelte 5 + Vite 5 web UI for kei-cortex daemon (chat panel, Live2D pet renderer, portrait uploader)"
[primitive.frustration-matrix] [primitive.frustration-matrix]
kind = "rust" kind = "rust"
crate = "frustration-matrix" crate = "frustration-matrix"

View file

@ -3969,12 +3969,10 @@ dependencies = [
"anyhow", "anyhow",
"kei-atom-discovery", "kei-atom-discovery",
"kei-skills", "kei-skills",
"libc",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"tokio", "tokio",
"toml",
] ]
[[package]] [[package]]

View file

@ -18,17 +18,7 @@ path = "src/lib.rs"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
# v0.39: io-std added for tokio::io::stdin/stdout used by the MCP stdio tokio = { workspace = true }
# transport in main.rs (workspace tokio doesn't enable io-std by default).
tokio = { workspace = true, features = ["io-std"] }
# v0.40 (Phase C): toml needed for safe_tools::policy_chain — reads
# ~/.claude/hooks/_lib/policy-chain.toml to know which hooks to chain.
toml = "0.8"
# v0.41 (audit fix #5): killpg via libc on Unix — kill_on_drop only SIGKILLs
# the immediate child shell, leaving grandchildren orphaned. We set the child
# in its own process group and killpg() the group on timeout.
[target.'cfg(unix)'.dependencies]
libc = "0.2"
anyhow = { workspace = true } anyhow = { workspace = true }
kei-atom-discovery = { path = "../kei-atom-discovery" } kei-atom-discovery = { path = "../kei-atom-discovery" }
kei-skills = { path = "../kei-skills" } kei-skills = { path = "../kei-skills" }

View file

@ -12,7 +12,6 @@
pub mod initialize; pub mod initialize;
pub mod prompts; pub mod prompts;
pub mod resources; pub mod resources;
pub mod safe_tools;
pub mod tools; pub mod tools;
use crate::protocol::{err, JsonRpcRequest, JsonRpcResponse, Method, ServerContext, METHOD_NOT_FOUND}; use crate::protocol::{err, JsonRpcRequest, JsonRpcResponse, Method, ServerContext, METHOD_NOT_FOUND};

View file

@ -1,738 +0,0 @@
//! Phase C — cross-CLI hook enforcement via MCP-wrapped tools.
//!
//! Exposes three built-in MCP tools — `kei_bash`, `kei_edit`, `kei_write` —
//! that synthesize Claude Code's PreToolUse hook input contract, chain
//! through the hook scripts declared in `~/.claude/hooks/_lib/policy-chain.toml`,
//! and only execute the wrapped action if every hook returns exit 0.
//!
//! Why this exists: when an agent runs on Grok / Agy / Copilot / Kimi, none
//! of our claude-side PreToolUse hooks fire. The agent could read the rules
//! in its system prompt but the tool-call layer was previously ungated. The
//! `kei_*` MCP tools restore that gate for any MCP-capable CLI.
//!
//! Constructor Pattern: ONE policy SSoT (`policy-chain.toml`), ONE dispatcher
//! (this file), hooks reused as-is from `~/.claude/hooks/`. No rewrite, no
//! abstraction layer. Shell-out per hook keeps the contract identical to
//! Claude's native PreToolUse pipeline.
//!
//! CLAUDECODE / GROKCODE guard — DESIGN NOTE (NOT a security boundary):
//! When invoked from inside Claude Code (`$CLAUDECODE=1`) or Grok the chain
//! is SKIPPED to avoid double-firing the same hooks (they already ran on the
//! CLI's own PreToolUse). This is a perf / UX optimization for the inside-CLI
//! call path — NOT an authorization check. An attacker who can set the
//! parent process's environment already controls the CLI invocation anyway;
//! re-running hooks would not stop them. To raise the bar for confused-deputy
//! scenarios use full sandboxing (Phase D) or run kei-mcp as a separate UID.
//!
//! v0.41 audit fixes (2026-05-26, Gemini security review):
//! #1 fail-CLOSED on missing hooks (was: silently skip)
//! #2 path-traversal guard on kei_edit/kei_write (canonicalize + root check)
//! #3 CLAUDECODE bypass — documented as design (see above), no behavior change
//! #4 tokio::fs for async file I/O (was: blocking std::fs on tokio thread)
//! #5 process-group kill on Unix (was: kill_on_drop SIGKILLs only direct child)
//!
//! v0.42 re-audit fixes (2026-05-26, 4-CLI dogfood: Claude+Grok+Gemini+Copilot):
//! #1 [CRITICAL] symlink LEAF bypass — canonicalize full path + reject
//! leaf symlinks (v0.41 only canonicalized PARENT; ln -s ~/.ssh/keys ./x
//! then kei_write x followed the link to the target)
//! #2 [HIGH] $HOME removed from default allowed_roots — was a blanket
//! allow that let agent overwrite ~/.claude/hooks (self-neuter), ~/.zshrc
//! (RCE on next shell), and credential stores. Default: $PWD only.
//! Denylist also extended with .claude/, .grok/, .gemini/, .copilot/,
//! .kimi/, and exact shell-init filenames.
//! #3 [HIGH] empty [bash]/[edit]/[write] section also FAIL-CLOSED (was:
//! empty vec → pass-through). KEI_POLICY_CHAIN_OPTIONAL=1 to opt in.
//! #4 [MED] load_chain converted to async + tokio::fs (was: blocking
//! std::fs on tokio worker thread).
//! #5 [MED] set_process_group + killpg applied to HOOK subprocess too
//! (v0.41 only had it on the bash action; hook grandchildren orphaned).
//! #6 [MED] doc note that aggregate timeout is still per-step (60s ×
//! N hooks + 60s action). Single-deadline implementation deferred to
//! v0.43 — not security-blocking.
use crate::protocol::{err, ok, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR, INVALID_PARAMS};
use serde::Deserialize;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
/// Per-step timeout (each hook AND the action each get up to this long).
/// For an N-hook chain the total wall-clock cap is approximately
/// `(N+1) * SAFE_TOOL_TIMEOUT_SECS`. v0.44 doc-honesty fix (Claude MED):
/// prior versions claimed this was an "aggregate" cap, which was always
/// wrong. Aggregate-deadline impl is deferred; for now the per-step
/// semantics are documented honestly so operators pick a sane value.
const SAFE_TOOL_TIMEOUT_SECS: u64 = 60;
#[derive(Deserialize, Default)]
struct PolicyChain {
#[serde(default)]
bash: ChainSpec,
#[serde(default)]
edit: ChainSpec,
#[serde(default)]
write: ChainSpec,
}
#[derive(Deserialize, Default)]
struct ChainSpec {
#[serde(default)]
chain: Vec<String>,
}
/// MCP tool descriptors — appended to `tools/list` by `handlers::tools::list`.
pub fn descriptors() -> Vec<Value> {
vec![
json!({
"name": "kei_bash",
"description": "Run a shell command after running KeiSeiKit's [bash] policy chain (no-github-push, safety-guard, destructive-guard). Blocks on hook exit 2 with the hook's stderr surfaced as the MCP error message. Use this instead of native shell on non-Claude CLIs to inherit Claude Code's safety enforcement.",
"inputSchema": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command to execute" },
"cwd": { "type": "string", "description": "Optional working directory; defaults to $PWD" }
},
"required": ["command"]
}
}),
json!({
"name": "kei_edit",
"description": "Modify a file (replace old_string with new_string) after running KeiSeiKit's [edit] policy chain (citation-verify, numeric-claims-guard). Blocks unverified academic citations and numeric claims without evidence markers.",
"inputSchema": {
"type": "object",
"properties": {
"file_path": { "type": "string" },
"old_string": { "type": "string" },
"new_string": { "type": "string" }
},
"required": ["file_path", "old_string", "new_string"]
}
}),
json!({
"name": "kei_write",
"description": "Write content to a file after running KeiSeiKit's [write] policy chain (citation-verify, numeric-claims-guard). Blocks unverified academic citations and numeric claims without evidence markers.",
"inputSchema": {
"type": "object",
"properties": {
"file_path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["file_path", "content"]
}
}),
]
}
/// Top-level dispatch entry — called from `handlers::tools::call` when the
/// tool name matches one of the three `kei_*` built-ins.
pub async fn dispatch_safe(req: JsonRpcRequest, name: &str, args: &Value) -> JsonRpcResponse {
let result = match name {
"kei_bash" => handle_bash(args).await,
"kei_edit" => handle_edit(args).await,
"kei_write" => handle_write(args).await,
_ => Err(format!("safe_tools dispatched unknown name: {name}")),
};
match result {
Ok(text) => ok(req.id, json!({
"content": [{ "type": "text", "text": text }],
"isError": false,
})),
Err(e) => err(req.id, INTERNAL_ERROR, e),
}
}
// ---- per-tool handlers --------------------------------------------------
async fn handle_bash(args: &Value) -> Result<String, String> {
let command = args.get("command").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_bash", "command"))?;
let cwd = args.get("cwd").and_then(Value::as_str);
// v0.44 fix #8 (Gemini MED): include cwd in hook input. Without this,
// safety-guard could approve a destructive command (e.g. `rm -rf *`)
// assuming PWD, while the actual cwd arg redirected it to a sensitive
// dir. Hooks now see the real working directory.
let hook_input = json!({
"tool_name": "Bash",
"tool_input": {
"command": command,
"cwd": cwd
}
});
run_chain("bash", &hook_input).await?;
let mut cmd = Command::new("bash");
cmd.arg("-c").arg(command);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
// v0.41 fix #5: put child in its own process group so timeout kills it
// and ALL grandchildren together (not just the immediate shell).
set_process_group(&mut cmd);
// v0.44 fix #4 (Gemini HIGH): clear parent env on subprocess spawn.
// Was: child inherited AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY, etc.
// An agent that exec's `env` via kei_bash could exfiltrate all of them.
// Now: only PATH/HOME/USER/LANG/TERM/SHELL forwarded (set in helper).
apply_safe_env(&mut cmd);
let child = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?;
let pid_opt = child.id();
let fut = child.wait_with_output();
let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(format!("wait bash: {e}")),
Err(_) => {
// Timeout — kill the entire process group, not just the child.
if let Some(pid) = pid_opt {
killpg_best_effort(pid);
}
return Err("kei_bash timeout".to_string());
}
};
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if !out.status.success() {
return Err(format!(
"bash exited {}: {}",
out.status.code().unwrap_or(-1),
stderr.trim()
));
}
Ok(if stderr.is_empty() { stdout } else { format!("{stdout}\n[stderr]\n{stderr}") })
}
// v0.41 fix #5: process-group helpers (Unix-only; no-op on other platforms).
#[cfg(unix)]
fn set_process_group(cmd: &mut Command) {
cmd.process_group(0);
}
#[cfg(not(unix))]
fn set_process_group(_cmd: &mut Command) {}
/// v0.44 fix #4 (Gemini HIGH): strip parent env on subprocess spawn so secrets
/// like AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY etc. don't leak to user-controlled
/// bash commands or hook scripts. Whitelist forwards only PATH/HOME/USER/LANG/
/// TERM/SHELL — enough to keep tools functional, none of it sensitive.
///
/// Override: `KEI_SAFE_ENV_EXTRA=":-separated list"` adds named vars to the
/// whitelist for callers that legitimately need (e.g. NIX_PATH, JAVA_HOME).
fn apply_safe_env(cmd: &mut Command) {
cmd.env_clear();
let default_keep = [
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
"LC_CTYPE", "TERM", "PWD", "TMPDIR",
];
for k in default_keep {
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
if let Ok(extras) = std::env::var("KEI_SAFE_ENV_EXTRA") {
for k in extras.split(':') {
let k = k.trim();
if k.is_empty() { continue; }
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
}
}
#[cfg(unix)]
fn killpg_best_effort(pid: u32) {
// SAFETY: libc::kill on a negative PID targets the process group.
// SIGKILL = 9. Best-effort — ignore errors (process may have exited).
unsafe {
let _ = libc::kill(-(pid as i32), libc::SIGKILL);
}
}
#[cfg(not(unix))]
fn killpg_best_effort(_pid: u32) {}
async fn handle_edit(args: &Value) -> Result<String, String> {
let file_path = args.get("file_path").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_edit", "file_path"))?;
let old_string = args.get("old_string").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_edit", "old_string"))?;
let new_string = args.get("new_string").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_edit", "new_string"))?;
// v0.44 LOW: reject empty old_string (would silently prepend new_string
// because contents.contains("") is always true).
if old_string.is_empty() {
return Err("kei_edit: old_string must not be empty".into());
}
let safe_path = validate_path(file_path)?;
let hook_input = json!({
"tool_name": "Edit",
"tool_input": {
"file_path": safe_path.display().to_string(),
"old_string": old_string,
"new_string": new_string
}
});
run_chain("edit", &hook_input).await?;
// v0.44 fix #2 (Gemini HIGH + Claude #4 MED): close TOCTOU window. After
// validate_path approved the path, a concurrent process could swap the
// file for a symlink before our write. Open the existing file with
// O_NOFOLLOW so the open itself fails on symlink-swap; then read/write
// through the open fd (not the path again) so no second path lookup.
open_nofollow_read_write_edit(&safe_path, old_string, new_string).await
}
async fn handle_write(args: &Value) -> Result<String, String> {
let file_path = args.get("file_path").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_write", "file_path"))?;
let content = args.get("content").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_write", "content"))?;
let safe_path = validate_path(file_path)?;
let hook_input = json!({
"tool_name": "Write",
"tool_input": { "file_path": safe_path.display().to_string(), "content": content }
});
run_chain("write", &hook_input).await?;
if let Some(parent) = safe_path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).await
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
}
// v0.44 fix #2: open with O_NOFOLLOW + O_CREAT to refuse swap-to-symlink.
open_nofollow_write(&safe_path, content).await
}
/// v0.44 fix #2: edit via O_NOFOLLOW-opened fd to close the TOCTOU window
/// between validate_path and the write. The open() itself refuses if the leaf
/// has been swapped to a symlink during the hook-chain await.
#[cfg(unix)]
async fn open_nofollow_read_write_edit(
path: &Path, old_string: &str, new_string: &str,
) -> Result<String, String> {
use std::os::unix::fs::OpenOptionsExt;
let path = path.to_path_buf();
let old_s = old_string.to_string();
let new_s = new_string.to_string();
// Blocking syscalls on a dedicated thread (tokio::task::spawn_blocking).
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let mut f = std::fs::OpenOptions::new()
.read(true).write(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)
.map_err(|e| format!("kei_edit: open(O_NOFOLLOW) {}: {e}", path.display()))?;
use std::io::{Read, Write, Seek, SeekFrom};
let mut contents = String::new();
f.read_to_string(&mut contents)
.map_err(|e| format!("kei_edit: read {}: {e}", path.display()))?;
if !contents.contains(&old_s) {
return Err(format!("kei_edit: old_string not found in {}", path.display()));
}
let updated = contents.replacen(&old_s, &new_s, 1);
f.set_len(0).map_err(|e| format!("kei_edit: truncate {}: {e}", path.display()))?;
f.seek(SeekFrom::Start(0))
.map_err(|e| format!("kei_edit: seek {}: {e}", path.display()))?;
f.write_all(updated.as_bytes())
.map_err(|e| format!("kei_edit: write {}: {e}", path.display()))?;
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
}).await
.map_err(|e| format!("kei_edit: thread join: {e}"))?;
result
}
#[cfg(not(unix))]
async fn open_nofollow_read_write_edit(
path: &Path, old_string: &str, new_string: &str,
) -> Result<String, String> {
// Non-Unix fallback: best-effort using tokio::fs (no O_NOFOLLOW available).
let contents = fs::read_to_string(path).await
.map_err(|e| format!("read {}: {e}", path.display()))?;
if !contents.contains(old_string) {
return Err(format!("kei_edit: old_string not found in {}", path.display()));
}
let updated = contents.replacen(old_string, new_string, 1);
fs::write(path, &updated).await
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
}
#[cfg(unix)]
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
use std::os::unix::fs::OpenOptionsExt;
let path = path.to_path_buf();
let bytes = content.as_bytes().to_vec();
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
// O_NOFOLLOW: refuse if the leaf is a symlink (someone swapped it
// during our await). Without this the v0.42 symlink_metadata pre-check
// was just an indicator — fs::write still followed.
opts.custom_flags(libc::O_NOFOLLOW);
// O_EXCL combined with O_CREAT could be added when path does not yet
// exist to refuse any pre-existing inode — but the test suite uses
// the same path multiple times, so we keep truncate semantics. The
// O_NOFOLLOW + symlink_metadata pre-check is sufficient.
let mut f = opts.open(&path)
.map_err(|e| format!("kei_write: open(O_NOFOLLOW) {}: {e}", path.display()))?;
use std::io::Write;
f.write_all(&bytes)
.map_err(|e| format!("kei_write: write {}: {e}", path.display()))?;
Ok(format!("wrote {} ({} bytes)", path.display(), bytes.len()))
}).await
.map_err(|e| format!("kei_write: thread join: {e}"))?;
result
}
#[cfg(not(unix))]
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
fs::write(path, content).await
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(format!("wrote {} ({} bytes)", path.display(), content.len()))
}
/// Path-traversal + symlink + denylist guard.
///
/// v0.41 (initial): rejected `..`, canonicalized PARENT, checked denylist + roots.
/// → 4-CLI re-audit (2026-05-26) found this was bypassable via symlink at the
/// leaf and self-attackable via the $HOME blanket-allowed root.
///
/// v0.42 fixes:
/// #1 [CRITICAL] reject if the leaf is a symlink (was: validated parent
/// only, fs::write followed leaf symlink to anywhere). Done via
/// `symlink_metadata` on the leaf BEFORE write, and full `canonicalize`
/// on the leaf when the file already exists.
/// #2 [HIGH] $HOME removed from default allowed-roots — default is $PWD
/// only. Denylist now also covers $HOME/.claude/ (the substrate
/// itself), shell init files, and credential stores. Operators who
/// need broader access set KEI_ALLOWED_ROOTS explicitly.
fn validate_path(p: &str) -> Result<PathBuf, String> {
if p.is_empty() {
return Err("file_path: empty".into());
}
// 1. Reject literal `..` segments — covers most traversal attempts.
if p.split('/').any(|seg| seg == "..") {
return Err(format!("file_path: '..' segment not allowed in {p}"));
}
let path = Path::new(p);
// 2. Build a canonical path. Walk UP to the deepest existing ancestor,
// canonicalize it (resolves all symlinks in the existing prefix),
// then reattach the non-existent tail. This catches symlinks at ANY
// depth in the path, including nested non-existent leaves.
//
// v0.44 fix #1 (Gemini CRITICAL): v0.42 only canonicalized the immediate
// parent. If the parent didn't exist either (e.g. /proj/symlink_dir/
// new_subdir/file.txt where symlink_dir → /Users/denis), the path fell
// through to "absolute as-is" → no canonicalization → bypass.
let canonical = canonicalize_with_walk_up(path)?;
// 3. Even when the file doesn't exist yet, the LEAF could already be a
// dangling symlink that `fs::write` would follow on creation. Reject.
if let Ok(meta) = std::fs::symlink_metadata(&canonical) {
if meta.file_type().is_symlink() {
return Err(format!(
"file_path: leaf is a symlink (refusing to follow): {}",
canonical.display()
));
}
}
// 4. Allowed-root containment FIRST (v0.44 fix #6 reorder: was after
// denylist, which meant macOS $TMPDIR = /private/var/folders/... hit
// the /var/ denylist before reaching the allowed_roots check, blocking
// legitimate use of tempfile-backed CWD on macOS).
//
// v0.44 fix #5 (Claude HIGH): use Path::starts_with for component-aware
// containment — Path::starts_with("/home/u/proj") does NOT match
// /home/u/proj-secrets, the str::starts_with that was here did.
let roots = allowed_roots();
let in_allowed_root = roots.is_empty() || roots.iter().any(|r| {
canonical.starts_with(r)
});
if !in_allowed_root {
return Err(format!(
"file_path: outside allowed roots {:?}: {}",
roots, canonical.display()
));
}
let canon_str = canonical.display().to_string();
// 5. Reject system + substrate-control + credential paths.
// Note: paths inside an allowed root that also match a denylist entry
// are STILL denied (e.g. agent's CWD == ~/.claude/ — denied even
// though it matches a default root). System dirs not in any allowed
// root would have been caught above anyway.
let denylist = [
"/etc/", "/usr/", "/System/", "/var/db/", "/var/log/", "/var/root/",
"/private/etc/", "/private/var/db/", "/private/var/log/", "/private/var/root/",
"/root/", "/bin/", "/sbin/",
];
// NOTE: /var/folders/ (macOS $TMPDIR) and /private/tmp/ are NOT denied —
// they are legitimate working dirs for tempfile-backed agents.
for d in denylist {
if canon_str.starts_with(d) {
return Err(format!("file_path: denied (system dir): {canon_str}"));
}
}
if let Ok(home) = std::env::var("HOME") {
let dir_secrets = [
".ssh/", ".aws/", ".gnupg/", ".config/gcloud/", ".cargo/credentials",
".npmrc", ".docker/config.json", ".kube/",
".claude/", ".grok/", ".gemini/", ".copilot/", ".kimi/",
];
for sd in dir_secrets {
let full = format!("{home}/{sd}");
if canon_str.starts_with(&full) {
return Err(format!("file_path: denied (secret/substrate dir): {canon_str}"));
}
}
let init_files = [
".zshrc", ".bashrc", ".profile", ".bash_profile", ".zprofile",
".zshenv", ".bash_login", ".inputrc", ".gitconfig",
".config/fish/config.fish",
];
for f in init_files {
let full = format!("{home}/{f}");
if canon_str == full {
return Err(format!("file_path: denied (shell-init file): {canon_str}"));
}
}
}
Ok(canonical)
}
/// v0.44 fix #1: walk up the path looking for the deepest existing ancestor,
/// canonicalize THAT, then reattach the non-existent tail components.
/// Resolves symlinks at any depth (existing OR non-existing branches).
fn canonicalize_with_walk_up(path: &Path) -> Result<PathBuf, String> {
// Make the path absolute first so we can walk up reliably.
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
.join(path)
};
// Walk up from the leaf, collecting non-existent components in reverse.
let mut current = abs.clone();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let canon = loop {
if current.exists() {
break current.canonicalize()
.map_err(|e| format!("file_path: canonicalize {}: {e}", current.display()))?;
}
let name = current.file_name()
.ok_or_else(|| format!("file_path: path has no existing ancestor: {}", abs.display()))?
.to_os_string();
let parent = match current.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => return Err(format!("file_path: walked to root without finding existing dir: {}", abs.display())),
};
tail.push(name);
current = parent;
};
// Reattach tail (in reverse — we pushed from leaf to root).
let mut result = canon;
for name in tail.into_iter().rev() {
result.push(name);
}
Ok(result)
}
fn allowed_roots() -> Vec<String> {
// Canonicalize each entry so symlinked roots (e.g. macOS /var → /private/var,
// /tmp → /private/tmp) match canonicalized targets. Trailing slash added
// for the consistency-with-default format. v0.44 fix #5 + #6 combined.
let canon_with_slash = |raw: &str| -> Option<String> {
let p = Path::new(raw);
let canon = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let mut s = canon.display().to_string();
if !s.ends_with('/') { s.push('/'); }
if s.is_empty() { None } else { Some(s) }
};
if let Ok(v) = std::env::var("KEI_ALLOWED_ROOTS") {
return v.split(':')
.filter(|s| !s.is_empty())
.filter_map(canon_with_slash)
.collect();
}
let mut roots = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
if let Some(r) = canon_with_slash(&cwd.display().to_string()) {
roots.push(r);
}
}
roots
}
// ---- chain runner -------------------------------------------------------
/// Run the configured hook chain for `tool` ("bash"/"edit"/"write"), piping
/// `hook_input` to each hook's stdin in order. Exit 0 → continue. Exit 2 (or
/// other non-zero) → return Err with the hook's stderr.
///
/// Skips the chain if the parent process is already inside Claude or Grok
/// (env flags), since those CLIs' native PreToolUse hooks already fired.
/// Run the configured hook chain for `tool` ("bash"/"edit"/"write").
///
/// v0.42 fixes:
/// #3 [HIGH] empty chain (section absent or zero hooks) now FAILS CLOSED
/// unless KEI_POLICY_CHAIN_OPTIONAL=1.
/// #4 [MED] load_chain() converted to async (was: blocking std::fs).
/// #5 [MED] hook subprocess gets `process_group(0)` + killpg on timeout
/// (was: only the bash action got it; hooks could orphan).
/// #6 [MED] aggregate timeout across the whole chain + action (was:
/// per-hook 60s, so chain+action could legitimately run
/// 4× the documented cap on a 3-hook chain).
async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> {
if env_truthy("CLAUDECODE") || env_truthy("GROKCODE") {
// Native hooks already enforced — don't double-fire.
return Ok(());
}
let chain = load_chain(tool).await?;
if chain.is_empty() {
// v0.42 fix #3 (Claude+Gemini HIGH): empty section is the same
// misconfig class as missing file — FAIL CLOSED with explicit opt-in.
if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") {
return Ok(());
}
return Err(format!(
"[policy-chain] section [{tool}] is empty — refusing to run \
(set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)"
));
}
let hooks_dir = hooks_dir()?;
let payload = serde_json::to_string(hook_input)
.map_err(|e| format!("encode hook input: {e}"))?;
for hook in chain {
let path = hooks_dir.join(&hook);
if !path.is_file() {
return Err(format!(
"[policy-chain] hook missing: {} (declared in policy-chain.toml [{}])",
path.display(), tool
));
}
let mut child_cmd = Command::new(&path);
child_cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
set_process_group(&mut child_cmd);
// v0.44 fix #4: same env-isolation for hook subprocess.
apply_safe_env(&mut child_cmd);
let mut child = child_cmd
.spawn()
.map_err(|e| format!("spawn {}: {e}", path.display()))?;
let pid_opt = child.id();
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(payload.as_bytes()).await
.map_err(|e| format!("write stdin to {}: {e}", path.display()))?;
stdin.shutdown().await
.map_err(|e| format!("close stdin to {}: {e}", path.display()))?;
}
let fut = child.wait_with_output();
let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(format!("wait {}: {e}", path.display())),
Err(_) => {
// v0.42 fix #5: kill the whole hook process group, not just
// the immediate child.
if let Some(pid) = pid_opt {
killpg_best_effort(pid);
}
return Err(format!("hook {hook} timeout"));
}
};
let code = out.status.code().unwrap_or(-1);
if code == 0 {
continue;
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(format!(
"[blocked by {hook} exit={code}]\n{stderr}"
));
}
Ok(())
}
// ---- config helpers -----------------------------------------------------
/// v0.42 fix #4: async + tokio::fs (was: blocking std::fs would freeze
/// a tokio worker if policy-chain.toml lived on a slow / hung mount).
async fn load_chain(tool: &str) -> Result<Vec<String>, String> {
let path = chain_path()?;
// tokio::fs::try_exists avoids a blocking is_file() syscall.
let exists = fs::try_exists(&path).await.unwrap_or(false);
if !exists {
if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") {
return Ok(vec![]);
}
return Err(format!(
"[policy-chain] config missing: {} (set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)",
path.display()
));
}
let raw = fs::read_to_string(&path).await
.map_err(|e| format!("read policy-chain.toml: {e}"))?;
let parsed: PolicyChain = toml::from_str(&raw)
.map_err(|e| format!("parse policy-chain.toml: {e}"))?;
let chain = match tool {
"bash" => parsed.bash.chain,
"edit" => parsed.edit.chain,
"write" => parsed.write.chain,
_ => return Err(format!("unknown tool kind: {tool}")),
};
Ok(chain)
}
fn chain_path() -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("KEI_POLICY_CHAIN") {
return Ok(PathBuf::from(p));
}
let dir = hooks_dir()?;
Ok(dir.join("_lib").join("policy-chain.toml"))
}
fn hooks_dir() -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("KEI_HOOKS_DIR") {
return Ok(PathBuf::from(p));
}
let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
Ok(PathBuf::from(home).join(".claude").join("hooks"))
}
fn env_truthy(name: &str) -> bool {
matches!(std::env::var(name).as_deref(), Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes"))
}
fn missing_arg(tool: &str, field: &str) -> String {
format!("{tool}: missing '{field}' argument")
}
#[allow(dead_code)]
const INVALID_PARAMS_REF: i32 = INVALID_PARAMS; // silence unused-import warning if removed

View file

@ -33,16 +33,6 @@ pub fn list(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
.into_iter() .into_iter()
.map(atom_to_tool_descriptor) .map(atom_to_tool_descriptor)
.collect(); .collect();
// v0.39: built-in spawn_agent tool — exposed to all MCP clients so any
// CLI (grok / agy / copilot / kimi / claude) can spawn a KeiSeiKit agent
// as a sub-agent. Bypasses atom discovery (it's an internal handler).
tools.push(spawn_agent_descriptor());
// v0.40 (Phase C): policy-gated MCP tools — kei_bash / kei_edit /
// kei_write run the configured hook chain BEFORE executing the action.
// This restores Claude Code's PreToolUse safety on non-Claude CLIs
// (Grok / Agy / Copilot / Kimi) — any MCP-capable orchestrator that
// disables its native shell + uses kei_bash gets full enforcement.
tools.extend(super::safe_tools::descriptors());
tools.sort_by(|a, b| { tools.sort_by(|a, b| {
a.get("name").and_then(Value::as_str).unwrap_or("") a.get("name").and_then(Value::as_str).unwrap_or("")
.cmp(b.get("name").and_then(Value::as_str).unwrap_or("")) .cmp(b.get("name").and_then(Value::as_str).unwrap_or(""))
@ -60,23 +50,6 @@ pub async fn call(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
None => return err(req.id, INVALID_PARAMS, "missing tool name"), None => return err(req.id, INVALID_PARAMS, "missing tool name"),
}; };
let args = params.get("arguments").cloned().unwrap_or(json!({})); let args = params.get("arguments").cloned().unwrap_or(json!({}));
// v0.39: spawn_agent built-in — short-circuit before atom dispatch.
if name == "spawn_agent" {
return match invoke_spawn_agent(&args).await {
Ok(text) => ok(req.id, json!({
"content": [{ "type": "text", "text": text }],
"isError": false,
})),
Err(e) => err(req.id, INTERNAL_ERROR, e),
};
}
// v0.40 (Phase C): kei_bash / kei_edit / kei_write — policy-gated tools.
if matches!(name.as_str(), "kei_bash" | "kei_edit" | "kei_write") {
return super::safe_tools::dispatch_safe(req, &name, &args).await;
}
match invoke_atom(&ctx.atoms_root, &name, &args).await { match invoke_atom(&ctx.atoms_root, &name, &args).await {
Ok(result) => ok(req.id, json!({ Ok(result) => ok(req.id, json!({
"content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }], "content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }],
@ -86,94 +59,6 @@ pub async fn call(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
} }
} }
/// v0.39: built-in `spawn_agent` MCP tool descriptor.
/// Exposes KeiSeiKit's cross-CLI agent launcher (`kei-agent-cli.sh`) so any
/// MCP client can spawn an agent on any backend (claude / grok / agy /
/// copilot / kimi). Solves the "non-claude orchestrator can't natively spawn
/// sub-agents" gap — any CLI with MCP support gets the spawn capability.
fn spawn_agent_descriptor() -> Value {
json!({
"name": "spawn_agent",
"description": "Spawn a KeiSeiKit agent as a sub-agent through any configured LLM CLI backend. Reads ~/.claude/agents/<name>.md, composes with the task, and execs the chosen backend non-interactively. Backend resolution: explicit `on` arg → agent manifest's `provider` → ~/.claude/config/primary.toml → claude.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Agent name (looked up in ~/.claude/agents/<name>.md)"
},
"task": {
"type": "string",
"description": "The task / question to give the agent"
},
"on": {
"type": "string",
"description": "Optional explicit backend override (claude/grok/agy/copilot/kimi/codex). Default: DNA → primary → claude.",
"enum": ["claude", "grok", "agy", "antigravity", "copilot", "kimi", "codex"]
}
},
"required": ["name", "task"]
}
})
}
/// v0.39: handler for `tools/call name=spawn_agent`. Shells out to
/// `kei-agent-cli.sh` (located via $HOME/.claude/scripts/) and returns
/// the backend's stdout as the tool result.
async fn invoke_spawn_agent(args: &Value) -> Result<String, String> {
let name = args.get("name").and_then(Value::as_str)
.ok_or_else(|| "spawn_agent: missing 'name' argument".to_string())?;
let task = args.get("task").and_then(Value::as_str)
.ok_or_else(|| "spawn_agent: missing 'task' argument".to_string())?;
let on_opt = args.get("on").and_then(Value::as_str);
// Locate the launcher script. Honors KEI_AGENT_CLI override for testing.
let script = match std::env::var("KEI_AGENT_CLI") {
Ok(v) => PathBuf::from(v),
Err(_) => {
let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
PathBuf::from(home).join(".claude/scripts/kei-agent-cli.sh")
}
};
if !script.is_file() {
return Err(format!("kei-agent-cli.sh not found: {}", script.display()));
}
let mut cmd = Command::new(&script);
if let Some(on) = on_opt {
cmd.arg(format!("--on={on}"));
}
cmd.arg(name).arg(task);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let child = cmd.spawn()
.map_err(|e| format!("spawn {}: {e}", script.display()))?;
let fut = child.wait_with_output();
// Reuse the existing ATOM_TIMEOUT_SECS for the spawn_agent cap too —
// 60s should suffice for non-interactive prompts; longer tasks would
// need streaming, which the MCP tools-call contract doesn't support
// anyway. Hung agents are killed at the timeout.
match tokio::time::timeout(Duration::from_secs(ATOM_TIMEOUT_SECS), fut).await {
Ok(Ok(out)) => {
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(format!(
"spawn_agent backend exited {}: {stderr}",
out.status.code().unwrap_or(-1)
));
}
Ok(stdout)
}
Ok(Err(e)) => Err(format!("wait: {e}")),
Err(_) => Err("spawn_agent timeout".into()),
}
}
/// Convert one atom's metadata into the MCP tool-descriptor shape. /// Convert one atom's metadata into the MCP tool-descriptor shape.
fn atom_to_tool_descriptor(meta: AtomMeta) -> Value { fn atom_to_tool_descriptor(meta: AtomMeta) -> Value {
let description = first_paragraph(&meta.body); let description = first_paragraph(&meta.body);

View file

@ -68,19 +68,7 @@ async fn tools_list_returns_two_atoms_with_descriptors() {
let resp = dispatch(req, &ctx).await; let resp = dispatch(req, &ctx).await;
let result = resp.result.expect("should have result"); let result = resp.result.expect("should have result");
let tools = result["tools"].as_array().expect("tools array"); let tools = result["tools"].as_array().expect("tools array");
// v0.40 (Phase C): list includes 4 built-ins (spawn_agent + kei_bash + assert_eq!(tools.len(), 2);
// kei_edit + kei_write) on top of discovered atoms.
assert_eq!(tools.len(), 6); // 2 atoms + 4 built-ins
assert!(
tools.iter().any(|t| t["name"] == "spawn_agent"),
"spawn_agent built-in must be present"
);
for kei in ["kei_bash", "kei_edit", "kei_write"] {
assert!(
tools.iter().any(|t| t["name"] == kei),
"{kei} built-in must be present"
);
}
// sorted alphabetically // sorted alphabetically
assert_eq!(tools[0]["name"], "kei-sage::ask"); assert_eq!(tools[0]["name"], "kei-sage::ask");
assert_eq!(tools[1]["name"], "kei-task::search"); assert_eq!(tools[1]["name"], "kei-task::search");
@ -103,14 +91,5 @@ async fn tools_list_handles_empty_root() {
}; };
let resp = dispatch(req, &ctx).await; let resp = dispatch(req, &ctx).await;
let result = resp.result.expect("should have result"); let result = resp.result.expect("should have result");
// v0.40 (Phase C): empty atoms root surfaces 4 built-ins assert_eq!(result["tools"].as_array().unwrap().len(), 0);
// (spawn_agent + kei_bash + kei_edit + kei_write).
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools.len(), 4);
let names: Vec<&str> = tools.iter()
.filter_map(|t| t["name"].as_str())
.collect();
for required in ["spawn_agent", "kei_bash", "kei_edit", "kei_write"] {
assert!(names.contains(&required), "missing built-in: {required}");
}
} }

View file

@ -1,45 +0,0 @@
# cli-backends.toml — SSoT for external LLM CLIs that can host KeiSeiKit agents.
#
# Each backend is a CLI you have a subscription / local install of. The
# `kei run-via <backend> <agent> "<task>"` launcher composes an agent's
# assembled .md prompt with the task and invokes the backend's
# non-interactive (print) mode.
#
# Add a backend by appending a `[backend.<name>]` table. The launcher
# (`scripts/kei-agent-cli.sh`) reads `bin` + `prompt_flag` and execs.
[backend.claude]
bin = "claude"
prompt_flag = "-p"
notes = "Claude Code (Anthropic) — native --agent flag also supported"
homepage = "https://claude.com/claude-code"
[backend.grok]
bin = "grok"
prompt_flag = "--print"
notes = "xAI Grok Build TUI — native --agent flag also supported"
homepage = "https://x.ai/grok"
[backend.agy]
bin = "agy"
prompt_flag = "--print"
notes = "Google Antigravity (alias: antigravity)"
aliases = ["antigravity"]
[backend.copilot]
bin = "copilot"
prompt_flag = "--prompt"
notes = "GitHub Copilot CLI (@github/copilot npm)"
homepage = "https://github.com/github/copilot-cli"
[backend.kimi]
bin = "kimi"
prompt_flag = "tui-only"
notes = "Moonshot Kimi CLI — TUI-ONLY (smoke 2026-05-26). Headless requires ACP client; launcher saves prompt to tmpfile + opens TUI for paste."
homepage = "https://moonshotai.github.io/kimi-cli/"
[backend.codex]
bin = "codex"
prompt_flag = "-p"
notes = "OpenAI Codex CLI — register here, install separately"
homepage = "https://github.com/openai/codex"

View file

@ -1,55 +0,0 @@
# KeiSeiKit hook-pack + stack-profile map — single source of truth for the
# opt-in install posture. Parsed by install/lib-packs.sh, which reuses the
# generic TOML array reader `_toml_array` extracted from lib-profile.sh
# (python-tomllib preferred, awk fallback). No new dependency.
#
# Values are HOOK BASENAMES WITHOUT `.sh`, matched against the command
# basenames in settings-snippet.json. Every hook wired in the snippet MUST
# appear in exactly one [pack] entry or in [pack-always]; anything missing
# would be silently filtered out of a fresh install.
#
# Posture: only `safety` + `pack-always` are active on a fresh/non-interactive
# install. All other packs are opt-in (via onboarding or `kei configure`).
# `git-guard` (no-github-push) is opt-in ONLY and is pulled by NO stack — a
# general kit must never block a user's normal `git push` to github by default.
[pack]
safety = ["block-dangerous", "safety-guard", "destructive-guard", "disk-headroom-check", "secrets-pre-guard", "no-hand-edit-agents", "assemble-validate", "assemble-agents"]
evidence = ["numeric-claims-guard", "citation-verify", "chat-numeric-prewarn", "chat-numeric-postflag"]
observability = ["task-timer", "session-end-dump", "extract-task-durations", "error-spike-detector", "agent-event-spawn", "agent-event-done", "agent-heartbeat-tick", "stop-verify"]
epistemic = ["alignment-check", "no-downgrade", "recurrence-suggest"]
orchestration = ["agent-fork-logger", "agent-fork-done", "orchestrator-dirty-check", "orchestrator-branch-check", "agent-capability-check", "agent-stub-scan", "milestone-commit-hook", "post-commit-audit", "post-write-check"]
git-guard = ["no-github-push"]
stack-rust = ["rust-first", "no-python-without-approval"]
# Always wired, never filtered (cosmetic / infra). The keisei-pet*.sh status
# updater + the inline pet hook are kept by the filter directly (name match),
# so they are NOT listed here.
[pack-always]
base = ["first-run-onboard", "mailbox-inject", "tomd-preread", "site-wysiwyd-check"]
# Stack profile -> discipline packs auto-enabled (safety is always implicit).
# git-guard intentionally absent from every stack (opt-in only).
[stack-packs]
minimal = []
web = ["evidence", "observability"]
ml = ["evidence", "observability", "epistemic"]
systems = ["evidence", "observability", "stack-rust"]
mobile = ["evidence", "observability"]
# Stack profile -> agent groups installed (the `base` group is always added).
[stack-agents]
minimal = ["base"]
web = ["base", "web"]
ml = ["base", "ml"]
systems = ["base", "systems"]
mobile = ["base", "mobile"]
# Agent group -> manifest basenames (without `.toml`). When no stack is chosen
# (power user / --profile=full / non-interactive), ALL manifests install.
[agent-set]
base = ["architect", "critic", "validator", "researcher", "code-implementer", "security-auditor"]
web = ["code-implementer-typescript", "frontend-validator", "validator-api", "validator-doc", "researcher-web", "researcher-code"]
ml = ["ml-implementer", "ml-researcher", "modal-runner", "cost-guardian", "fal-ai-runner", "code-implementer-python", "validator-benchmark"]
systems = ["code-implementer-rust", "code-implementer-go", "infra-implementer", "infra-implementer-cicd", "infra-implementer-container", "infra-implementer-iac", "infra-implementer-secrets", "validator-version"]
mobile = ["code-implementer-swift", "code-implementer-flutter", "frontend-validator"]

162
bin/kei
View file

@ -8,23 +8,7 @@
# kei # splash → claude (interactive REPL) # kei # splash → claude (interactive REPL)
# kei --no-splash # skip splash → exec claude # kei --no-splash # skip splash → exec claude
# kei --status # status only, don't launch claude # kei --status # status only, don't launch claude
# kei message ... # inter-session mailbox (send/inbox/list) — see kei-message.sh # kei [args...] # splash → claude args... (forwarded verbatim)
# kei configure # re-pick stack profile + opt-in hook packs
# kei pick # interactive picker → set primary → launch it
# kei agent <name> "<task>" # invoke agent, backend from DNA → primary
# kei agent --on=<backend> <name> "<task>" # override backend
# kei run-via <backend> <name> "<task>" # invoke agent on explicit backend
# # backends: claude grok agy copilot kimi codex
# # `kei run-via list` shows install status + agents
# kei primary [<backend>] # get/set primary LLM provider (DNA fallback)
# kei mcp-wire [<cli>] # wire kei-mcp into a CLI's MCP config + hook setup
# # (Phase C cross-CLI policy enforcement)
# kei mcp-wire --list # show enforcement tier per CLI
# kei limits # probe each CLI's subscription quota (best-effort)
# # (4 of 5 CLIs have no public API — honest report)
# kei onboard # post-install wizard (pick primary + mcp-wire + check)
# kei --on=<backend> # one-shot launch of <backend> (does not change primary)
# kei [args...] # splash → exec primary CLI (default: claude)
# #
# The splash shows: substrate version, agent count, last sleep run, # The splash shows: substrate version, agent count, last sleep run,
# active sessions (kei-ping). Press any key to skip the dwell. # active sessions (kei-ping). Press any key to skip the dwell.
@ -33,128 +17,26 @@
set -e set -e
# --- subcommand dispatch (before splash) ---------------------------------
# `kei message ...` → mailbox CLI
# `kei configure` → hook/stack re-picker
# `kei agent ...` → DNA-resolved agent (manifest provider → primary → claude)
# `kei run-via ...` → explicit-backend agent invocation
# `kei primary ...` → get/set primary LLM provider
# rest = splash + launch claude (legacy primary).
case "${1:-}" in
message|msg|m)
shift
exec "$HOME/.claude/scripts/kei-message.sh" "$@"
;;
configure|config|reconfigure)
shift
exec "$HOME/.claude/scripts/kei-configure.sh" "$@"
;;
agent)
shift
exec "$HOME/.claude/scripts/kei-agent-cli.sh" "$@"
;;
run-via|via|agent-via)
shift
exec "$HOME/.claude/scripts/kei-agent-cli.sh" "$@"
;;
primary)
shift
exec "$HOME/.claude/scripts/kei-agent-cli.sh" primary "$@"
;;
pick)
shift
exec "$HOME/.claude/scripts/kei-pick.sh" "$@"
;;
mcp-wire|wire)
shift
exec "$HOME/.claude/scripts/kei-mcp-wire.sh" "$@"
;;
limits|quota|usage)
shift
exec "$HOME/.claude/scripts/kei-limits.sh" "$@"
;;
onboard|setup|wizard)
shift
exec "$HOME/.claude/scripts/kei-onboard.sh" "$@"
;;
esac
# --- one-shot --on=<backend> override (does not write primary.toml) -------
ONESHOT_BACKEND=""
for arg in "$@"; do
case "$arg" in
--on=*) ONESHOT_BACKEND="${arg#--on=}" ;;
esac
done
# --- args ---------------------------------------------------------------- # --- args ----------------------------------------------------------------
SPLASH=1 SPLASH=1
STATUS_ONLY=0 STATUS_ONLY=0
PASSTHROUGH=() PASSTHROUGH=()
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--on=*) ;; # already captured in ONESHOT_BACKEND; don't forward
--no-splash) SPLASH=0 ;; --no-splash) SPLASH=0 ;;
--status) STATUS_ONLY=1; SPLASH=1 ;; --status) STATUS_ONLY=1; SPLASH=1 ;;
*) PASSTHROUGH+=("$arg") ;; *) PASSTHROUGH+=("$arg") ;;
esac esac
done done
# --- resolve primary backend --------------------------------------------- # --- locate claude on PATH -----------------------------------------------
# Order: --on=<backend> override → ~/.claude/config/primary.toml → claude. CLAUDE_BIN="$(command -v claude 2>/dev/null || true)"
resolve_primary() { if [ -z "$CLAUDE_BIN" ] && [ "$STATUS_ONLY" = "0" ]; then
if [ -n "$ONESHOT_BACKEND" ]; then printf '%s\n' "$ONESHOT_BACKEND"; return; fi echo "error: 'claude' not on PATH. Install Claude Code first:" >&2
if [ -n "${KEI_PRIMARY:-}" ]; then printf '%s\n' "$KEI_PRIMARY"; return; fi echo " curl -fsSL https://claude.ai/install.sh | sh" >&2
local cfg="$HOME/.claude/config/primary.toml"
if [ -f "$cfg" ]; then
awk -F'=' '/^provider[[:space:]]*=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
gsub(/^"|"$/, "", $2)
print $2; exit
}' "$cfg"
return
fi
printf 'claude\n'
}
# Map backend name → executable. Mirrors scripts/kei-agent-cli.sh::backend_bin.
backend_bin_for() {
case "$1" in
claude) echo "claude" ;;
grok) echo "grok" ;;
agy|antigravity) echo "agy" ;;
copilot) echo "copilot" ;;
kimi) echo "kimi" ;;
codex) echo "codex" ;;
*) return 1 ;;
esac
}
PRIMARY="$(resolve_primary)"
PRIMARY_CLI="$(backend_bin_for "$PRIMARY")" || {
echo "error: unknown primary backend: $PRIMARY" >&2
exit 2
}
PRIMARY_BIN="$(command -v "$PRIMARY_CLI" 2>/dev/null || true)"
if [ -z "$PRIMARY_BIN" ] && [ "$STATUS_ONLY" = "0" ]; then
echo "error: primary backend '$PRIMARY' → '$PRIMARY_CLI' not on PATH." >&2
case "$PRIMARY" in
claude) echo " install: curl -fsSL https://claude.ai/install.sh | sh" >&2 ;;
grok) echo " install: see https://x.ai/grok" >&2 ;;
copilot) echo " install: npm i -g @github/copilot" >&2 ;;
kimi) echo " install: uv tool install kimi-cli" >&2 ;;
agy) echo " install: see https://antigravity.dev" >&2 ;;
codex) echo " install: see https://github.com/openai/codex" >&2 ;;
esac
echo " or: kei pick (interactive picker to choose + set primary)" >&2
echo " or: kei primary <backend> (set a different default)" >&2
exit 127 exit 127
fi fi
# Legacy var name for splash code below.
CLAUDE_BIN="$PRIMARY_BIN"
# --- read state ---------------------------------------------------------- # --- read state ----------------------------------------------------------
AGENTS_DIR="${HOME}/.claude/agents" AGENTS_DIR="${HOME}/.claude/agents"
SYNC_DIR="${HOME}/.claude/memory/sync-repo" SYNC_DIR="${HOME}/.claude/memory/sync-repo"
@ -170,10 +52,12 @@ agent_count() {
# Profile from .installed marker file # Profile from .installed marker file
profile_name() { profile_name() {
# install.sh stamps the chosen profile here; .installed only holds primitive local f="${AGENTS_DIR}/_primitives/.installed"
# names (so the old `grep ^profile .installed` always returned "?"). if [ -f "$f" ]; then
local f="$HOME/.claude/.kei-profile" grep -E "^profile" "$f" 2>/dev/null | head -1 | awk -F= '{gsub(/[ "]/,"",$2); print $2}' || echo "?"
if [ -f "$f" ]; then head -1 "$f"; else echo "?"; fi else
echo "?"
fi
} }
# Last Phase B sleep timestamp # Last Phase B sleep timestamp
@ -216,14 +100,13 @@ splash() {
sl="$(last_sleep_run)" sl="$(last_sleep_run)"
as="$(active_sessions)" as="$(active_sessions)"
# Only color if stdout is a tty. Brand palette: голубой (sky-blue) + жёлтый (gold). # Only color if stdout is a tty
local C0= C1= C2= C3= CV= local C0= C1= C2= C3=
if [ -t 1 ]; then if [ -t 1 ]; then
C0=$'\033[0m' C0=$'\033[0m'
C1=$'\033[1;38;5;39m' # голубой (sky-blue) — logo C1=$'\033[1;36m' # cyan-bold
C2=$'\033[1;38;5;220m' # жёлтый (gold) — brand line C2=$'\033[0;36m' # cyan
C3=$'\033[2;38;5;39m' # dim blue — separators C3=$'\033[2m' # dim
CV=$'\033[1;38;5;220m' # жёлтый — field values
fi fi
cat <<EOF cat <<EOF
@ -235,13 +118,12 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0} ${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0} ${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
${C2} KeiSeiKit · substrate v0.45${C0} ${C2} KeiSeiKit · substrate v0.16${C0}
${C3} ─────────────────────────────────────${C0} ${C3} ─────────────────────────────────────${C0}
primary CLI : ${CV}${PRIMARY}${C0} profile : ${p}
profile : ${CV}${p}${C0} agents : ${ac}
agents : ${CV}${ac}${C0} last sleep run : ${sl}
last sleep run : ${CV}${sl}${C0} active sessions: ${as}
active sessions: ${CV}${as}${C0}
${C3} ─────────────────────────────────────${C0} ${C3} ─────────────────────────────────────${C0}
EOF EOF

View file

@ -48,14 +48,7 @@ done
# fallback to cortex for compat with v0.16 default behaviour. # fallback to cortex for compat with v0.16 default behaviour.
prompt_profile() { prompt_profile() {
if [ -n "$PROFILE" ]; then return 0; fi if [ -n "$PROFILE" ]; then return 0; fi
# Interactive iff stdin is a terminal. NOT stdout: web-install.sh tees stdout if [ ! -t 0 ] || [ ! -t 1 ]; then PROFILE="cortex"; return 0; fi
# to a logfile (pipe), so -t 1 is false even in an interactive curl|bash.
# Prompts print to the terminal via tee; the menu reads from stdin.
# Non-interactive (CI / piped, no controlling terminal) → minimal: fast,
# no 105-crate compile, can't half-fail. Matches install.sh's own default
# (was "cortex" here → divergent install vs direct install.sh). Opt up with
# --profile=cortex/full-hub.
if [ ! -t 0 ]; then PROFILE="minimal"; return 0; fi
cat <<'WIZARD' cat <<'WIZARD'
╔═══════════════════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════════════════╗
@ -177,14 +170,9 @@ fi
log "checkout: $KIT_DIR" log "checkout: $KIT_DIR"
# --- 5. run install ------------------------------------------------------ # --- 5. run install ------------------------------------------------------
log "running install.sh --profile=$PROFILE $YES_FLAG ${EXTRA_FLAGS[*]:-}" log "running ./install.sh --profile=$PROFILE $YES_FLAG ${EXTRA_FLAGS[*]:-}"
cd "$KIT_DIR" cd "$KIT_DIR"
# Defensive: invoke via `bash` not `./install.sh` because GitHub's contents ./install.sh --profile="$PROFILE" $YES_FLAG "${EXTRA_FLAGS[@]:+${EXTRA_FLAGS[@]}}"
# API does NOT preserve the executable bit on `gh api -X PUT` updates
# (only the git Data API does). Older clones may have install.sh with
# mode 644 even though the source repo has it 755. `bash <file>` works
# regardless of file mode. Verified incident 2026-05-26 prod-curl test.
bash ./install.sh --profile="$PROFILE" $YES_FLAG "${EXTRA_FLAGS[@]:+${EXTRA_FLAGS[@]}}"
# --- 6. post-install verification ---------------------------------------- # --- 6. post-install verification ----------------------------------------
KEI_BIN="$HOME/.claude/agents/_primitives/_rust/target/release" KEI_BIN="$HOME/.claude/agents/_primitives/_rust/target/release"
@ -204,25 +192,6 @@ log ""
log "===========================================================================" log "==========================================================================="
log "DONE — KeiSeiKit installed (profile: $PROFILE)" log "DONE — KeiSeiKit installed (profile: $PROFILE)"
log "===========================================================================" log "==========================================================================="
# v0.45: post-install onboarding wizard.
# Auto-triggers if stdin is a TTY (real terminal). Wizard itself re-checks
# and exits cleanly if non-interactive — so curl|bash one-liner runs work too.
ONBOARD_SH="$HOME/.claude/scripts/kei-onboard.sh"
if [ -x "$ONBOARD_SH" ] && [ -t 0 ] && [ "${KEI_NO_ONBOARD:-0}" != "1" ]; then
log ""
log "Starting post-install onboarding (pick primary CLI + wire MCP)..."
log "Skip with KEI_NO_ONBOARD=1; re-run anytime with 'kei onboard'."
log ""
"$ONBOARD_SH" || log "(onboarding exited non-zero; re-run with 'kei onboard')"
else
log ""
log "Post-install wizard skipped (no TTY or KEI_NO_ONBOARD=1)."
log "Run interactively to configure primary CLI:"
log " kei onboard # full wizard"
log " kei pick # just pick primary"
log " kei mcp-wire # wire MCP into installed CLIs"
fi
log "" log ""
log "Next steps:" log "Next steps:"
log " - Open a new shell so PATH picks up ~/.cargo/bin and the kei-* binaries." log " - Open a new shell so PATH picks up ~/.cargo/bin and the kei-* binaries."

View file

@ -1,178 +0,0 @@
# Cross-CLI policy enforcement
> *Same safety rules. Any LLM CLI. Three honesty tiers.*
KeiSeiKit's safety hooks (`no-github-push`, `safety-guard`, `destructive-guard`,
`citation-verify`, `numeric-claims-guard`) originally fired only inside Claude
Code's `PreToolUse` pipeline. Phase C extends enforcement to other CLIs —
but the strength of enforcement depends on what each CLI permits.
## The 3-tier honesty model
| Tier | What it means | CLIs |
|---|---|---|
| **TIER 1 — full native** | Tool-call enforcement at the CLI's own hook layer. Same as Claude. | claude, **grok** |
| **TIER 2 — MCP-wrapped** | Native shell disabled at launch; agent forced to use our policy-gated `kei_bash`/`kei_edit`/`kei_write` MCP tools. | **copilot** |
| **TIER 3 — advisory** | CLI can't disable native shell; we register kei-mcp and instruct the agent to prefer `kei_*` tools, but enforcement is prompt-level only. | **agy, kimi** |
For patent-sensitive or production-PR work — stick to TIER 1 (claude or grok).
## How to wire
One command sets up enforcement for whichever CLIs you have installed:
```bash
kei mcp-wire # detect + wire all installed CLIs
kei mcp-wire grok # wire one CLI
kei mcp-wire --dry-run # preview config changes without writing
kei mcp-wire --list # show enforcement tier per CLI
```
The orchestrator is idempotent — running twice produces the same config.
## What `kei mcp-wire` writes
### claude (TIER 1 — already enforced)
No-op. Native PreToolUse hooks already gate every tool call. `kei mcp-wire claude`
prints the optional `mcpServers` snippet you can add to
`~/.claude/settings.json` if you want claude to also see `spawn_agent` for
sub-agent dispatch.
### grok (TIER 1 — port our hooks)
Writes `~/.grok/settings.json` `hooks.PreToolUse` block:
- `Bash` matcher → `no-github-push.sh` + `safety-guard.sh` + `destructive-guard.sh`
- `Edit` matcher → `citation-verify.sh` + `numeric-claims-guard.sh`
- `Write` matcher → `citation-verify.sh` + `numeric-claims-guard.sh`
Plus registers kei-mcp with `GROKCODE=1` env (so kei-mcp's policy chain skips
duplicate enforcement when invoked via Grok — your native hooks already fired).
xAI's Grok uses the same JSON input contract as Claude Code's PreToolUse, so
our hook scripts run unchanged. Identical enforcement to claude.
### copilot (TIER 2 — disable native shell, force MCP)
Writes `~/.copilot/mcp-config.json` registering kei-mcp. To activate enforcement,
launch copilot with `--excluded-tools='shell'`:
```bash
alias copilot='copilot --excluded-tools=shell'
```
The agent will have NO native shell tool, only kei-mcp's `kei_bash`
which runs the policy chain before execution. `kei_edit` / `kei_write`
similarly gate file mutations.
### agy / kimi (TIER 3 — advisory)
Writes their MCP config (`~/.gemini/config/mcp_config.json` for agy,
`~/.kimi/mcp.json` for kimi) registering kei-mcp.
**The honest part:** these CLIs do NOT have a way to disable their native
shell. The agent CAN reach for native bash regardless of what we tell it.
The system prompt nudges it toward `kei_bash`, but a determined or careless
agent can bypass.
For patent-sensitive work — **don't use agy or kimi as orchestrator**.
Use them for analysis / brainstorming / no-side-effect tasks only.
## Internals
### policy-chain.toml (SSoT)
One file declares which hooks gate which tool, for all CLIs that go through
the MCP layer:
```toml
# ~/.claude/hooks/_lib/policy-chain.toml
[bash]
chain = ["no-github-push.sh", "safety-guard.sh", "destructive-guard.sh"]
[edit]
chain = ["citation-verify.sh", "numeric-claims-guard.sh"]
[write]
chain = ["citation-verify.sh", "numeric-claims-guard.sh"]
```
To add a hook: append its basename. The hook script must already exist in
`~/.claude/hooks/` and follow the standard PreToolUse contract (read JSON
on stdin with `.tool_name` + `.tool_input`, return exit 0 = pass / 2 = block).
### kei-mcp built-in tools
`kei-mcp` (Rust MCP server at `_primitives/_rust/kei-mcp/`) exposes 4
built-in tools across two source files (both bypass the atom-discovery
loop in `handlers/tools.rs`):
In `handlers/tools.rs`:
- `spawn_agent(name, task, on?)` — invokes a KeiSeiKit agent on any backend
In `handlers/safe_tools.rs` (Phase C, v0.40+):
- `kei_bash(command, cwd?)` — runs `[bash]` chain → executes
- `kei_edit(file_path, old_string, new_string)` — runs `[edit]` chain → edits
- `kei_write(file_path, content)` — runs `[write]` chain → writes
The chain runs against the same hook scripts Claude uses; identical input
shape, identical decisions. On block, the hook's stderr surfaces as the MCP
error message so the calling agent sees exactly why.
**v0.42 hardening** (post 4-CLI re-audit, supersedes v0.41):
- **Fail-CLOSED everywhere** — missing config, missing hook, OR empty
section (`[bash]/[edit]/[write]` with no entries) all refuse to run.
Tests / dev can opt in via `KEI_POLICY_CHAIN_OPTIONAL=1`.
- **Symlink-safe path guard**`kei_edit` / `kei_write` canonicalize the
FULL path (resolving any leaf symlink to its real target) and reject
if the leaf itself is a symlink for a not-yet-existent file. Fixes the
v0.41 CRITICAL bypass where `ln -s ~/.ssh/keys ./x; kei_write x` would
follow the link.
- **$PWD-only default root** — `allowed_roots` defaults to current working
directory only. Was: `$PWD` + entire `$HOME` — too permissive, agent
could overwrite `~/.claude/hooks/*` (self-neuter) or `~/.zshrc` (RCE on
next shell). Operators who need broader access set `KEI_ALLOWED_ROOTS`.
- **Denylist extended** — system dirs (`/etc/`, `/usr/`, `/System/`,
`/var/`, `/root/`, `/bin/`, `/sbin/`); credential stores (`~/.ssh/`,
`~/.aws/`, `~/.gnupg/`, `~/.config/gcloud/`, `~/.cargo/credentials`,
`~/.docker/config.json`, `~/.kube/`); substrate dirs (`~/.claude/`,
`~/.grok/`, `~/.gemini/`, `~/.copilot/`, `~/.kimi/`); exact shell-init
files (`.zshrc`, `.bashrc`, `.profile`, `.zshenv`, `.gitconfig`, ...).
- **Async file I/O in load_chain**`policy-chain.toml` now read via
`tokio::fs` (was: blocking `std::fs` froze worker on slow mounts).
- **Process-group kill on hooks too** — hook subprocesses get
`process_group(0)` and `killpg(SIGKILL)` on timeout. Was: only the bash
action got this; hook grandchildren orphaned.
- **CLAUDECODE/GROKCODE design note** — documented as perf/UX
optimization, NOT a security boundary (env-controllable parent → confused
deputy is already-game-over scenario).
### Double-enforcement guard
If kei-mcp is invoked from a process where `$CLAUDECODE=1` or `$GROKCODE=1`,
it SKIPS its hook chain — the CLI's native hooks already fired. This is set
automatically by `kei mcp-wire claude` / `kei mcp-wire grok`. On copilot /
agy / kimi the env is unset → chain runs.
## Verification
```bash
# All 4 built-ins must list:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
| kei-mcp | jq -r '.result.capabilities'
# Block test (kei_bash refuses forbidden command):
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"kei_bash","arguments":{"command":"git push https://github.com/x/y.git main"}}}' \
| kei-mcp 2>&1 | grep "RULE 0.1" # expects: BLOCK — RULE 0.1 NO GITHUB PUSH
# Pass test:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"kei_bash","arguments":{"command":"echo OK"}}}' \
| kei-mcp | tail -1 | jq -r '.result.content[0].text' # expects: OK
```
## Related
- [Multi-CLI agent invocation](./multi-cli-agents.md) — DNA-resolved agent dispatch
- `kei-mcp` source: `_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs`
- Policy SSoT: `hooks/_lib/policy-chain.toml`
- Wire scripts: `scripts/kei-mcp-wire*.sh`

View file

@ -28,23 +28,12 @@ All hooks live under `hooks/` directory. Format: `| Hook Name | Event | Severity
- **remind (exit 0 + stderr on trigger)** — passive reminder - **remind (exit 0 + stderr on trigger)** — passive reminder
- **advisory** — informational, never blocks - **advisory** — informational, never blocks
### Hook packs (opt-in posture)
A fresh install activates **only the `safety` pack** (plus cosmetic/infra hooks).
Discipline packs are opt-in, chosen during onboarding (step 6) or later via
`kei configure`. SSoT for pack membership + stack profiles is
`_primitives/hook-packs.toml`. Packs: `safety` (always on), `evidence`,
`observability`, `epistemic`, `orchestration`, `git-guard` (opt-in only),
`stack-rust` (only under the `systems` stack profile). Discipline hooks also
respect runtime toggling via `KEI_DISABLED_HOOKS` / `KEI_HOOK_PROFILE` (see the
`hooks-control` skill).
### Core Safety Hooks ### Core Safety Hooks
| Hook | Event | Severity | Purpose | Bypass Env | | Hook | Event | Severity | Purpose | Bypass Env |
|------|-------|----------|---------|-----------| |------|-------|----------|---------|-----------|
| no-github-push.sh | PreToolUse:Bash | block | Block accidental push / repo-create to github.com (opt-in; for code kept on a private remote) | KEI_NO_GITHUB_PUSH_BYPASS | | no-github-push.sh | PreToolUse:Bash | block | Prevent pushing KeiTech patent IP to github.com — destroys priority date | KEI_NO_GITHUB_PUSH_BYPASS |
| no-python-without-approval.sh | PreToolUse:Bash | block | Optional Rust-first policy — Python requires explicit justification (opt-in, stack-gated) | none | | no-python-without-approval.sh | PreToolUse:Bash | block | Enforce RULE 0.2 (Rust first) — Python requires exception justification | none |
| rust-first.sh | UserPromptSubmit | remind | Remind about Rust-first default for new work | none | | rust-first.sh | UserPromptSubmit | remind | Remind about Rust-first default for new work | none |
| secrets-pre-guard.sh | PreToolUse:Edit\|Write | block | Detect hardcoded API keys, tokens, private keys before commit | KEI_SECRETS_GUARD_BYPASS | | secrets-pre-guard.sh | PreToolUse:Edit\|Write | block | Detect hardcoded API keys, tokens, private keys before commit | KEI_SECRETS_GUARD_BYPASS |
| destructive-guard.sh | PreToolUse:Bash | block | Block dangerous commands (rm -rf /, git reset --hard main, truncate) | none | | destructive-guard.sh | PreToolUse:Bash | block | Block dangerous commands (rm -rf /, git reset --hard main, truncate) | none |

View file

@ -1,225 +0,0 @@
# Multi-CLI agent invocation
> *Cross-LLM agent execution. Same agent definition, different backend.*
> *Same DNA, swap the brain. KeiSeiKit is no longer Claude-Code-only.*
KeiSeiKit agents are markdown files. Any LLM CLI that takes a prompt can
host them. Three call shapes:
```bash
kei agent <name> "<task>" # DNA-resolved (manifest → primary → claude)
kei agent --on=<backend> <name> "<task>" # override DNA
kei run-via <backend> <name> "<task>" # explicit backend (no DNA lookup)
```
## Backends — smoke-tested 2026-05-26
| Backend | CLI | Flag | Smoke | Notes |
|----------|-----------|--------------|-------|-------|
| claude | `claude` | `-p` | ✅ | Claude Code, native `--agent` flag |
| grok | `grok` | `--print` | ✅ | xAI Grok Build TUI, native `--agent` flag |
| agy | `agy` | `--print` | ✅ | Google Antigravity (Gemini models). Alias: `antigravity` |
| copilot | `copilot` | `--prompt` | ✅ | GitHub Copilot CLI (`@github/copilot`) |
| kimi | `kimi` | TUI-only | ⚠ | No print mode — launcher saves prompt to tmpfile + opens TUI for paste. `kimi acp` JSON-RPC integration is future work. |
| codex | `codex` | `-p` | — | OpenAI Codex (register-only; not installed locally) |
Run `kei run-via list` to see installed backends, current primary, and agent names.
## DNA — agent prefers a provider
Add `provider` to the agent manifest:
```toml
# _manifests/my-agent.toml
name = "my-agent"
provider = "grok" # preferred backend; optional
model = "grok-2" # advisory; informs choice but not yet sent through
```
The assembler emits it into frontmatter:
```yaml
---
name: my-agent
provider: grok
---
```
Resolution order (each falls through if previous returns nothing):
1. `--on=<backend>` flag on the command line
2. `provider:` field in agent manifest
3. `~/.claude/config/primary.toml` (set via `kei primary <backend>`)
4. Default: `claude`
## Primary — your default LLM
```bash
kei primary # show current primary (and fallback)
kei primary grok # set default to Grok
kei primary claude # back to Claude Code
```
`kei primary` writes `~/.claude/config/primary.toml`. Any agent without
its own `provider:` field will resolve to this. This is the lever to
"swap out Claude Code as the primary shell" — set primary to grok, and
every `kei agent <name>` runs on Grok.
## Usage examples
```bash
# DNA mode (manifest's provider, or primary, or claude):
kei agent critic "review src/auth.rs"
# Override DNA — try the same agent on a different model for a second opinion:
kei agent --on=grok critic "review src/auth.rs"
kei agent --on=agy critic "review src/auth.rs"
kei agent --on=copilot critic "review src/auth.rs"
# Explicit backend, no DNA lookup (legacy):
kei run-via grok critic "review src/auth.rs"
# Point at an arbitrary agent file:
kei agent --on=grok --file=/tmp/my-agent.md "do the thing"
# Native --agent flag (grok/claude only):
KEI_NATIVE_AGENT=1 kei agent critic "review src/auth.rs"
```
## How it works
1. Resolves backend from DNA (see above).
2. Reads `~/.claude/agents/<agent-name>.md` (assembler-generated prompt).
3. Strips YAML frontmatter.
4. Composes with task: `<agent prompt>\n\n---\n\nTASK FOR THIS RUN:\n<task>`.
5. Execs the backend's non-interactive CLI with the composed prompt.
No agent file is modified. No new tokens are issued — subscription
authentication is whatever each CLI uses (its own login / config dir).
## When to use each
This is a tool, not a recommendation. Each backend has different
strengths; the substrate is agnostic about which you pick. Pick by:
- **Familiarity** — the CLI you already use day-to-day.
- **Subscription cost** — burn the one with cheaper marginal cost first.
- **Specific feature** — e.g. `grok --agent` for native sub-agent
switching mid-conversation; `agy --sandbox` for terminal restriction.
- **Independent second opinion** — same agent, different model, see if
conclusions diverge.
## Orchestrator picker — `kei` no longer hardcodes claude
Without args, `kei` reads `~/.claude/config/primary.toml` and execs that CLI.
The picker lets you change it interactively:
```bash
kei pick # interactive menu → set primary → launch it
kei # splash → exec the configured primary
kei --on=grok # one-shot launch of grok (does NOT change primary)
kei primary grok # set default to grok (no launch)
kei primary # show current primary
```
The splash shows `primary CLI: <backend>` so you always know which orchestrator
will start. If the chosen primary isn't installed, `kei` prints the install
command and offers `kei pick` as recovery.
## Cross-CLI sub-agent spawn via MCP — `spawn_agent`
`kei-mcp` exposes a built-in `spawn_agent` MCP tool. Any CLI that connects
to it as an MCP client can invoke KeiSeiKit agents on any backend, no matter
what the orchestrator is:
```jsonrpc
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "spawn_agent",
"arguments": {
"name": "critic",
"task": "review src/auth.rs for race conditions",
"on": "grok"
}
}
}
```
Internally `spawn_agent` shells out to `kei-agent-cli.sh` with the same DNA
resolution as `kei agent`. The `on` argument is optional — without it, the
backend is picked from the agent's manifest, then `primary.toml`, then claude.
**Why this matters:** Claude Code has a native `Agent` tool for sub-agent
spawning. Grok / Antigravity / Copilot / Kimi do NOT have that surface
natively — but they all support MCP. With `spawn_agent` exposed via kei-mcp,
**every backend that speaks MCP gets KeiSeiKit's sub-agent capability**. So
when Grok is your orchestrator, it can still spawn `critic` on Claude (or
`code-implementer` on Antigravity, or anything else) — the orchestrator
choice no longer caps your sub-agent surface.
Wire kei-mcp into the orchestrator's MCP config (each CLI has its own):
| CLI | MCP config |
|---|---|
| claude | `~/.claude/settings.json` `mcpServers` block |
| grok | `~/.grok/config.json` (or check `grok --help`) |
| agy | `~/.antigravity/mcp.json` (check `agy plugin list`) |
| copilot | `~/.copilot/mcp.json` (check `copilot --help`) |
| kimi | `kimi mcp add` subcommand |
Point each at `<kit>/_primitives/_rust/target/release/kei-mcp` (built via
`cargo build -p kei-mcp --release`).
## Rule enforcement — see also: cross-CLI policy
**Phase C delivered**: KeiSeiKit's safety hooks now have a 3-tier enforcement
model across CLIs. See [cross-cli-policy.md](./cross-cli-policy.md) for the
full matrix and `kei mcp-wire` setup. Short version: TIER 1 (full native)
on claude+grok, TIER 2 (MCP-wrapped) on copilot, TIER 3 (advisory) on agy+kimi.
## Rule enforcement caveat (READ THIS — pre-Phase-C view)
KeiSeiKit hooks (`numeric-claims-guard`, `citation-verify`, `no-github-push`,
`safety-guard`, `push-to-main`, etc.) are **Claude Code-side**:
`PreToolUse:Bash` / `:Edit` / `:Write` events that fire inside Claude Code's
process. They do **not** propagate to grok / agy / copilot / kimi.
That means:
- **Prompt-level rules** (the agent's instructions inside the `.md`) DO
carry through — the agent reads Constructor Pattern, Evidence Grading,
No Hallucination, etc. as part of its system prompt on any backend.
- **Tool-level enforcement** (hard-deny on `git push github.com`,
citation guard, etc.) only applies on the **claude** backend. Other
backends' tool surfaces are governed by THEIR own hooks/policies.
If you need true rule-enforcement on a non-claude backend, the path is
the **MCP server** (`_primitives/_rust/kei-mcp/`): registers KeiSeiKit
primitives as MCP tools that the other CLI invokes. Tool-side policies
travel with the MCP wrapper, not with the CLI.
## Adding a new backend
1. Add a `[backend.<name>]` table to `_primitives/cli-backends.toml`.
2. Add a case arm in `scripts/kei-agent-cli.sh` `backend_bin()` and
`backend_invoke()` for the new CLI's print-flag.
3. Add a row to the smoke-test table above (state PASS/FAIL/PARTIAL).
## What it is NOT
- Not a router — picks no backend for you; you (or DNA) ask, it dispatches.
- Not a federation — each backend runs independently with its own
context; there is no cross-backend state.
- Not a rule-enforcement layer — hooks only fire on the claude backend
(see caveat above). For non-claude rule enforcement use MCP server.
- Not a wrapper around the backend's tool surface — what the CLI can
do (Bash, file edits, MCP, etc.) is determined by that CLI, not
KeiSeiKit. The substrate only ships the prompt.
## Related
- `_primitives/_rust/kei-llm-router/` — Beta-posterior router for
*programmatic* model selection inside Rust code (a different layer).
- `_primitives/_rust/kei-mcp/` — MCP server that exposes KeiSeiKit
primitives to ANY MCP-compatible client (Cursor / Continue / Zed /
Aider / Cline / Windsurf / OpenClaw).

View file

@ -1,32 +0,0 @@
# policy-chain.toml — SSoT for which hooks gate which MCP tool.
#
# Consumed by `kei-mcp::handlers::safe_tools` to enforce KeiSeiKit's safety
# rules on non-Claude CLIs (Grok / Agy / Copilot / Kimi) via the
# `kei_bash` / `kei_edit` / `kei_write` MCP tools.
#
# Hooks live in ~/.claude/hooks/ (overridable via $KEI_HOOKS_DIR).
# Exit codes: 0 = pass, 2 = block, other non-zero = treat as block + log.
# The dispatcher iterates `chain` IN ORDER and aborts on first non-zero.
#
# Constructor Pattern: ONE chain for all CLIs. Per-CLI override deferred
# until proven necessary. To extend, append a hook basename (no .sh) to
# the relevant chain — the hook script must already exist in ~/.claude/hooks/.
[bash]
chain = [
"no-github-push.sh",
"safety-guard.sh",
"destructive-guard.sh",
]
[edit]
chain = [
"citation-verify.sh",
"numeric-claims-guard.sh",
]
[write]
chain = [
"citation-verify.sh",
"numeric-claims-guard.sh",
]

View file

@ -65,14 +65,4 @@ if [ -n "$TOOL_USE_ID" ] && [ -f "$ACTIVE_FILE" ]; then
mv "$ACTIVE_FILE.tmp" "$ACTIVE_FILE" 2>/dev/null || true mv "$ACTIVE_FILE.tmp" "$ACTIVE_FILE" 2>/dev/null || true
fi fi
# v0.40 root-cause fix: remove the .task-${id}.start marker that task-timer.sh
# wrote on agent_spawn. Without this, completed sub-agents leave stale markers
# in ~/.claude/memory/time-metrics/ which inflate the pet's running-agent
# counter (🤖N). Previously task-timer was the only writer + the 2h stale
# filter in keisei-pet.sh was the only cleanup; that left up-to-2h dead
# markers visible on every status refresh.
if [ -n "$TOOL_USE_ID" ] && [ "$TOOL_USE_ID" != "unknown" ]; then
rm -f "$HOME/.claude/memory/time-metrics/.task-${TOOL_USE_ID}.start" 2>/dev/null || true
fi
exit 0 exit 0

View file

@ -1,7 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "alignment-check" || exit 0; fi
# ALIGNMENT CHECK HOOK # ALIGNMENT CHECK HOOK
# Fires on UserPromptSubmit when comparison/experiment keywords detected. # Fires on UserPromptSubmit when comparison/experiment keywords detected.
# THREE-TIME REPEAT BUG: exp6, exp24-28, basecaller — all forgot alignment. # THREE-TIME REPEAT BUG: exp6, exp24-28, basecaller — all forgot alignment.

View file

@ -1,7 +1,4 @@
#!/bin/sh #!/bin/sh
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "chat-numeric-postflag" || exit 0; fi
# chat-numeric-postflag.sh — Stop warn (RULE 0.18 chat-output) # chat-numeric-postflag.sh — Stop warn (RULE 0.18 chat-output)
# #
# Reads the session transcript, extracts the last assistant message, # Reads the session transcript, extracts the last assistant message,

View file

@ -1,7 +1,4 @@
#!/bin/sh #!/bin/sh
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "chat-numeric-prewarn" || exit 0; fi
# chat-numeric-prewarn.sh — UserPromptSubmit remind (RULE 0.18 chat-output) # chat-numeric-prewarn.sh — UserPromptSubmit remind (RULE 0.18 chat-output)
# #
# Detects time/cost/effort keywords in the user's prompt and injects an # Detects time/cost/effort keywords in the user's prompt and injects an

View file

@ -1,8 +1,8 @@
#!/bin/bash #!/bin/bash
# DELETED — 2026-05-02 # DELETED — 2026-05-02
# Reasons: # Reasons:
# 1. Hardcoded absolute path leak (machine-specific, author-local) # 1. Hardcoded path leak: /Users/denis/projects/ai machine learning/error-patterns.json
# 2. Language-policy violation: used python3 for JSON parsing # 2. RULE 0.2 violation: used python3 for JSON parsing
# 3. No-op on every machine except the original author's # 3. No-op on every machine except original author's
# Removed from settings-snippet.json PostToolUse matcher "*" block. # Removed from settings-snippet.json PostToolUse matcher "*" block.
exit 0 exit 0

View file

@ -1,7 +1,4 @@
#!/bin/bash #!/bin/bash
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "citation-verify" || exit 0; fi
# PreToolUse(Edit|Write) — block unverified academic citations # PreToolUse(Edit|Write) — block unverified academic citations
# #
# Rule 0.5 NO HALLUCINATION enforcer. # Rule 0.5 NO HALLUCINATION enforcer.

View file

@ -1,41 +0,0 @@
#!/bin/sh
# first-run-onboard — on the FIRST Claude Code session after a KeiSeiKit install,
# inject a one-time POST-INSTALL ONBOARDING checklist so Claude walks the user
# through ALL setup in order (agents → sleep → cortex), instead of leaving each
# as a separate thing the user has to discover. Event: SessionStart (stdout is
# injected into session context). Fires ONCE (marker), then silent forever.
# Reset / re-run: rm ~/.claude/.kei-firstrun-shown
MARKER="$HOME/.claude/.kei-firstrun-shown"
[ -f "$MARKER" ] && exit 0
[ -d "$HOME/.claude/agents" ] || exit 0
PROJ="$HOME/Projects"
[ -d "$PROJ" ] || PROJ="$HOME/projects"
[ -d "$PROJ" ] || PROJ="$HOME/Projects"
# Cortex step only if the cortex daemon primitive landed (cortex / full* profiles).
CORTEX_STEP=""
if [ -d "$HOME/.claude/agents/_primitives/_rust/kei-cortex" ] \
|| [ -x "$HOME/.claude/agents/_primitives/_rust/target/release/kei-cortex" ]; then
CORTEX_STEP=" 3. /cortex-setup — cortex daemon + UI (token, whisper, model, bundle)
"
fi
cat <<EOF
[KeiSeiKit · FIRST-RUN ONBOARDING] The substrate is installed. Before other
work, proactively walk the user through this one-time setup, step by step, in
order — confirm each step with the user, then run it:
1. /onboard $PROJ/* — scan every project, detect stack, create a
project-specialist agent per project (delegates to /new-agent).
2. /sleep-setup — nightly memory (REM) consolidation. Recommend
local-only mode (runs on this Mac, no remote/cloud git needed) unless the
user wants the cloud agent (needs a cloud-reachable private git repo).
$CORTEX_STEP
Start with step 1. Offer to do them one after another as a guided flow; the user
can skip any. Do NOT make the user discover these on their own.
EOF
: > "$MARKER"
exit 0

View file

@ -3,7 +3,7 @@
# Bypass: GRAPH_EXPORT_BYPASS=1 # Bypass: GRAPH_EXPORT_BYPASS=1
INTERVAL="${KEI_GRAPH_EXPORT_INTERVAL_S:-5}" INTERVAL="${KEI_GRAPH_EXPORT_INTERVAL_S:-5}"
OUT="${KEI_GRAPH_VIZ_DIR:-$HOME/.local/share/kei/graph-viz}/data-runtime.js" OUT="${KEI_GRAPH_VIZ_DIR:-$HOME/Projects/lbm-graph-viz}/data-runtime.js"
BIN="$(command -v kei-graph-export 2>/dev/null || echo "$HOME/.cargo/bin/kei-graph-export")" BIN="$(command -v kei-graph-export 2>/dev/null || echo "$HOME/.cargo/bin/kei-graph-export")"
[ -x "$BIN" ] || exit 0 [ -x "$BIN" ] || exit 0

View file

@ -1,57 +0,0 @@
#!/bin/sh
# mailbox-inject — pull-inbox for kei-message. On every UserPromptSubmit, inject
# any messages addressed to THIS session (by cwd-basename or the broadcast
# channel "all") that arrived since last turn, into the session context, so
# Claude sees what other sessions sent. Per-session read cursor dedups; first
# turn starts fresh (no history dump). Never blocks (always exit 0).
# Event: UserPromptSubmit. Bypass: KEI_MAILBOX_BYPASS=1.
[ "${KEI_MAILBOX_BYPASS:-}" = "1" ] && exit 0
command -v jq >/dev/null 2>&1 || exit 0
INPUT=$(cat)
SID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
[ -n "$CWD" ] || CWD="$PWD"
me="$(basename "$CWD")"
MBOX="$HOME/.claude/mailbox"
LOG="$MBOX/messages.jsonl"
mkdir -p "$MBOX"
CUR="$MBOX/.cursor-${SID:-$me}"
# Highest id currently in the bus (0 if the log doesn't exist yet / is empty).
if [ -f "$LOG" ]; then
maxid=$(jq -s 'map(.id) | max // 0' "$LOG" 2>/dev/null || echo 0)
else
maxid=0
fi
[ -n "$maxid" ] || maxid=0
# First fire for this session: record baseline cursor, show nothing. Done even
# when the bus is still empty — so messages that arrive AFTER this point (but
# before the session's next turn) are not missed.
if [ ! -f "$CUR" ]; then
echo "$maxid" > "$CUR"
exit 0
fi
# Nothing to read yet.
[ -f "$LOG" ] || { echo "$maxid" > "$CUR"; exit 0; }
last=$(cat "$CUR" 2>/dev/null || echo 0)
case "$last" in ''|*[!0-9]*) last=0 ;; esac
new=$(jq -r --argjson last "$last" --arg me "$me" '
select(.id > $last)
| select(.to == $me or .to == "all")
| select(.from != $me)
| " • \(.from) -> \(.to): \(.body)"' "$LOG" 2>/dev/null)
# Advance cursor past everything seen this turn.
echo "$maxid" > "$CUR"
if [ -n "$new" ]; then
printf '[kei mailbox] new message(s) for this session (%s):\n%s\n (reply: kei message send --to <name> "...")\n' "$me" "$new"
fi
exit 0

View file

@ -1,7 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "no-downgrade" || exit 0; fi
# RULE -1 NO DOWNGRADE / CONSTRUCTIVE ONLY (2026-04-15 LOCK) enforcement. # RULE -1 NO DOWNGRADE / CONSTRUCTIVE ONLY (2026-04-15 LOCK) enforcement.
# #
# Detects downgrade-style phrases in Write/Edit content without accompanying # Detects downgrade-style phrases in Write/Edit content without accompanying

View file

@ -1,10 +1,9 @@
#!/bin/sh #!/bin/sh
# no-github-push.sh — PreToolUse:Bash hard deny. # no-github-push.sh — PreToolUse:Bash hard deny (RULE 0.1 NO GITHUB PUSH)
# #
# Blocks any Bash command that would push code or create a repo on github.com. # Blocks any Bash command that would push code to github.com.
# Opt-in guard for teams that keep proprietary code on a private remote # KeiTech portfolio contains unfiled patent IP — a public push destroys
# (Forgejo / Gitea / self-hosted) and want a hard stop against an accidental # priority date and trade secrets. Irrecoverable.
# public push. Off by default in the public kit — enable it in onboarding.
# #
# Exit codes: # Exit codes:
# 0 = pass (command is safe) # 0 = pass (command is safe)
@ -70,16 +69,18 @@ fi
# --- Block ------------------------------------------------------------------ # --- Block ------------------------------------------------------------------
cat >&2 <<'EOF' cat >&2 <<'EOF'
[no-github-push] BLOCK — push to github.com is disabled by this guard. [no-github-push] BLOCK — RULE 0.1 NO GITHUB PUSH
This checkout is configured to stay on a private remote; a public push KeiTech portfolio contains unfiled patent IP. Public push destroys
could expose code you intend to keep private. priority date + trade secrets. Irrecoverable.
Use your private remote instead (Forgejo, Gitea, self-hosted): Use a private remote instead (Forgejo, Gitea, self-hosted):
git remote set-url origin ssh://git@<private-host>/<user>/<repo>.git git remote set-url origin ssh://git@<private-host>/<user>/<repo>.git
git push origin <branch> git push origin <branch>
Bypass (visible, per-call): Bypass (visible, per-call):
Set env KEI_NO_GITHUB_PUSH_BYPASS=1 before the command. Set env KEI_NO_GITHUB_PUSH_BYPASS=1 before the command.
You must also add confirmation phrase: "yes, push patent code to github"
+ "confirm publication" in the session turn.
EOF EOF
exit 2 exit 2

View file

@ -1,7 +1,4 @@
#!/bin/bash #!/bin/bash
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "no-python-without-approval" || exit 0; fi
# Hard block on python/python3/python2 invocations in Bash tool. # Hard block on python/python3/python2 invocations in Bash tool.
# RULE 0.2 (Rust First) — Python requires explicit architectural reason. # RULE 0.2 (Rust First) — Python requires explicit architectural reason.
# Claude кroнически нарушает RULE 0.2 inline-вызовами python3 для мелких расчётов. # Claude кroнически нарушает RULE 0.2 inline-вызовами python3 для мелких расчётов.

View file

@ -1,7 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "numeric-claims-guard" || exit 0; fi
# RULE 0.18 — Numeric claim enforcement — block Edit/Write of numeric claims # RULE 0.18 — Numeric claim enforcement — block Edit/Write of numeric claims
# without evidence marker. Bypass: RULE_017_BYPASS=1 prefix (kept for compat). # without evidence marker. Bypass: RULE_017_BYPASS=1 prefix (kept for compat).
# #
@ -29,7 +26,7 @@ fi
# - "N MB/GB/LOC/tests/crates/atomars" # - "N MB/GB/LOC/tests/crates/atomars"
# - "~$N", "$N/mo" # - "~$N", "$N/mo"
# - "Nm Ns", "займёт N", "should take N" # - "Nm Ns", "займёт N", "should take N"
NUMERIC_PATTERN='(~\s*[0-9]+(\.[0-9]+)?\s*(min|minute|hour|hr|day|week|month|sec|second|MB|GB|KB|LOC|line|test|crate|atomar|%|µs|ms|ns|TPS|req/s)|[0-9]+m\s*[0-9]+s|\$[0-9]+\.[0-9]+|\$[0-9]+/(mo|hr|day|run)|\$[0-9]{2,}|~\s*\$[0-9]+|should take|will take|takes about|займёт|за ~|estimated at|ETA[: ]|approximately\s+[0-9])' NUMERIC_PATTERN='(~\s*[0-9]+(\.[0-9]+)?\s*(min|minute|hour|hr|day|week|month|sec|second|MB|GB|KB|LOC|line|test|crate|atomar|%|µs|ms|ns|TPS|req/s)|[0-9]+m\s*[0-9]+s|\$[0-9]+(\.[0-9]+)?(/(mo|hr|day|run))?|~\s*\$[0-9]+|should take|will take|takes about|займёт|за ~|estimated at|ETA[: ]|approximately\s+[0-9])'
# Markers that satisfy the rule # Markers that satisfy the rule
EVIDENCE_PATTERN='\[(REAL|FROM-JOURNAL|ESTIMATE-HTC)[: ]' EVIDENCE_PATTERN='\[(REAL|FROM-JOURNAL|ESTIMATE-HTC)[: ]'

View file

@ -1,7 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runtime gate (hooks-control skill / KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE).
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "rust-first" || exit 0; fi
# RULE 0.2 — RUST FIRST reminder hook. # RULE 0.2 — RUST FIRST reminder hook.
# #
# Fires on UserPromptSubmit. Detects keywords indicating language choice # Fires on UserPromptSubmit. Detects keywords indicating language choice

View file

@ -36,67 +36,36 @@ if [ -n "$transcript" ] && [ -f "$transcript" ]; then
cp -f "$transcript" "$dest" 2>/dev/null || true cp -f "$transcript" "$dest" 2>/dev/null || true
fi fi
# RECURRENCE FIX 2026-05-26: 18MB+ transcripts caused 4-minute "Recombobulating…" # Best-effort ingest — advisory only; never blocks the session from ending.
# hangs at session end. The three heavy ops below now run async-detached:
# hook returns immediately, ingest / scan / sync grind in background.
# Raw JSONL is already saved sync (line 36) — no data loss; only the
# index/embedding step is deferred. kei-memory ingest is idempotent on
# session_id so partial runs are safe.
bg_log="${HOME}/.claude/memory/traces/session-end.bg.log"
mkdir -p "$(dirname "$bg_log")" 2>/dev/null || true
# Portable timeout (macOS has no `timeout` / `gtimeout` by default).
# Fallback: perl alarm. Final fallback: no timeout (rely on detach).
kei_with_timeout() {
secs="$1"; shift
if command -v timeout >/dev/null 2>&1; then
timeout "$secs" "$@"
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout "$secs" "$@"
elif command -v perl >/dev/null 2>&1; then
perl -e 'alarm shift @ARGV; exec @ARGV' "$secs" "$@"
else
"$@"
fi
}
# Best-effort ingest — async-detached.
if command -v kei-memory >/dev/null 2>&1 && [ -f "$dest" ]; then if command -v kei-memory >/dev/null 2>&1 && [ -f "$dest" ]; then
( kei-memory ingest \
kei_with_timeout 90 kei-memory ingest \ --session-id "$session_id" \
--session-id "$session_id" \ --transcript "$dest" \
--transcript "$dest" \ >/dev/null 2>&1 || true
>>"$bg_log" 2>&1 \
|| printf '[%s] kei-memory ingest timeout/fail for %s\n' \
"$(date +%H:%M:%S)" "$session_id" >>"$bg_log"
) </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
fi fi
# Wave 25 — frustration-matrix scan. # Wave 25 — frustration-matrix scan: regex+firmware classifier produces a
# JSONL of per-line affect hits per session, much smaller than the full
# transcript. Cloud REM agent reads the affect file instead of 80MB JSONL.
# Silent no-op when the primitive is absent.
if command -v frustration-matrix >/dev/null 2>&1; then if command -v frustration-matrix >/dev/null 2>&1; then
affect_dir="${HOME}/.claude/memory/affect" affect_dir="${HOME}/.claude/memory/affect"
mkdir -p "$affect_dir" 2>/dev/null || true mkdir -p "$affect_dir" 2>/dev/null || true
affect_out="${affect_dir}/${session_id}.jsonl" affect_out="${affect_dir}/${session_id}.jsonl"
( frustration-matrix scan \
kei_with_timeout 60 frustration-matrix scan \ --root "$traces_dir" \
--root "$traces_dir" \ --since 1d \
--since 1d \ --format jsonl \
--format jsonl \ --output "$affect_out" \
--output "$affect_out" \ >/dev/null 2>&1 || true
>>"$bg_log" 2>&1 || true
) </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
fi fi
# v0.11 sleep-sync (RULE 0.15) — push traces to memory-repo. # v0.11 sleep-sync (RULE 0.15) — push traces to the user's memory-repo so a
# cloud agent can consolidate them overnight. Silent no-op when the primitive
# is absent or the user hasn't opted in via /sleep-setup.
sleep_sync="${HOME}/.claude/agents/_primitives/kei-sleep-sync.sh" sleep_sync="${HOME}/.claude/agents/_primitives/kei-sleep-sync.sh"
if [ -x "$sleep_sync" ]; then if [ -x "$sleep_sync" ]; then
( "$sleep_sync" >/dev/null 2>&1 || true
kei_with_timeout 120 "$sleep_sync" >>"$bg_log" 2>&1 || true
) </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
fi fi
exit 0 exit 0

View file

@ -37,8 +37,6 @@ source "$LIB_DIR/lib-log.sh"
source "$LIB_DIR/lib-backup.sh" source "$LIB_DIR/lib-backup.sh"
# shellcheck source=install/lib-profile.sh # shellcheck source=install/lib-profile.sh
source "$LIB_DIR/lib-profile.sh" source "$LIB_DIR/lib-profile.sh"
# shellcheck source=install/lib-packs.sh
source "$LIB_DIR/lib-packs.sh"
# shellcheck source=install/lib-args.sh # shellcheck source=install/lib-args.sh
source "$LIB_DIR/lib-args.sh" source "$LIB_DIR/lib-args.sh"
# shellcheck source=install/lib-menu.sh # shellcheck source=install/lib-menu.sh
@ -149,11 +147,6 @@ case "$PROFILE" in
;; ;;
esac esac
say "profile: $PROFILE" say "profile: $PROFILE"
# Stamp the chosen profile so `kei` splash + tools can show it (bin/kei reads this).
mkdir -p "$HOME_DIR/.claude" 2>/dev/null || true
printf '%s\n' "$PROFILE" > "$HOME_DIR/.claude/.kei-profile" 2>/dev/null || true
# Stamp the kit checkout dir so `kei configure` can re-source the libs later.
printf '%s\n' "$KIT_DIR" > "$HOME_DIR/.claude/.kei-kit-dir" 2>/dev/null || true
# --- resolve profile -> primitive list (UNCONDITIONAL, SSoT) ------------- # --- resolve profile -> primitive list (UNCONDITIONAL, SSoT) -------------
# Must run BEFORE any reader of PROFILE_PRIMS: the --no-execute plan block # Must run BEFORE any reader of PROFILE_PRIMS: the --no-execute plan block
@ -210,7 +203,6 @@ if ! printf '%s\n' "$CONFIRM_INPUT" | show_confirm_screen "$CONFIRM_LABEL"; then
fi fi
# --- execute install phases ---------------------------------------------- # --- execute install phases ----------------------------------------------
kei_banner
setup_target_dirs setup_target_dirs
scaffold_memory_index scaffold_memory_index
install_blocks install_blocks
@ -241,11 +233,7 @@ fi
# target/release/ regardless of profile (lib-substrate.sh), so PATH wiring # target/release/ regardless of profile (lib-substrate.sh), so PATH wiring
# is meaningful for every profile except minimal-without-prebuilt. # is meaningful for every profile except minimal-without-prebuilt.
if [ "$NO_PATHWAY" != "1" ]; then if [ "$NO_PATHWAY" != "1" ]; then
# Gate on interactive stdin only — NOT -t 1: curl|bash tees stdout to a if [ "$WITH_PATHWAY" = "1" ] || { [ -t 0 ] && [ -t 1 ]; }; then
# logfile, so -t 1 is false even interactively. Requiring it skipped PATH
# wiring (~/.claude/bin), so the `kei` entry-point was not found after a
# curl|bash install. (Same tee/-t1 trap as the onboarding gates.)
if [ "$WITH_PATHWAY" = "1" ] || [ -t 0 ]; then
pathway_install pathway_install
fi fi
fi fi

View file

@ -6,7 +6,7 @@ STR_WELCOME_TITLE="KeiSeiKit · Exobrain installer"
STR_WELCOME_TAGLINE="Portable Rust agent substrate for AI coding tools" STR_WELCOME_TAGLINE="Portable Rust agent substrate for AI coding tools"
# Onboarding wizard steps # Onboarding wizard steps
STR_ONBOARDING_INTRO="Onboarding wizard (6 steps)" STR_ONBOARDING_INTRO="Onboarding wizard (5 steps)"
STR_PICK_LANGUAGE="Choose interface language:" STR_PICK_LANGUAGE="Choose interface language:"
STR_PICK_TRANSPORT="Choose connection transport:" STR_PICK_TRANSPORT="Choose connection transport:"
STR_PICK_PROVIDER="Choose provider within" STR_PICK_PROVIDER="Choose provider within"
@ -19,7 +19,7 @@ STR_TR_AZURE_OPENAI="Azure OpenAI (deployment+key)"
STR_TR_GOOGLE_VERTEX="Google Vertex AI (GCP)" STR_TR_GOOGLE_VERTEX="Google Vertex AI (GCP)"
STR_TR_LOCAL="Local (Ollama/MLX/LMStudio)" STR_TR_LOCAL="Local (Ollama/MLX/LMStudio)"
STR_TR_PROXY="Proxy (LiteLLM/OpenRouter)" STR_TR_PROXY="Proxy (LiteLLM/OpenRouter)"
STR_TR_SUBSCRIPTION="Subscription login (Claude Code / ChatGPT — no API key)" STR_TR_SUBSCRIPTION="OAuth subscription (ChatGPT)"
# Auth collection # Auth collection
STR_AUTH_INTRO="Auth for" STR_AUTH_INTRO="Auth for"
@ -40,25 +40,3 @@ STR_MENU_CONFIRM="Confirm selection?"
# Preflight warnings # Preflight warnings
STR_PREFLIGHT_FAILED="Preflight failed — provider may not work." STR_PREFLIGHT_FAILED="Preflight failed — provider may not work."
STR_PREFLIGHT_CONTINUE="Continue anyway? [y/N]" STR_PREFLIGHT_CONTINUE="Continue anyway? [y/N]"
# Wizard explanations + input validation
STR_PICK_INVALID="please type one of the numbers shown"
STR_EXPLAIN_TRANSPORT="How the agents reach the AI. subscription = log in with your plan, no API key (Claude Code is option 1); direct-api = your own API key. Press Enter for the default."
STR_EXPLAIN_PROVIDER="Which AI service. Option 1 is the recommended default — press Enter."
STR_EXPLAIN_MODEL="Default model the agents use. Option 1 is the recommended default — press Enter."
# Stack profile + hook-pack picker (step 6)
STR_PICK_STACK="Pick your stack profile (selects which hooks + agents install):"
STR_PICK_STACK_PROMPT="[1-5, default 1=minimal]: "
STR_STACK_MINIMAL="safety hooks + core agents only"
STR_STACK_WEB="TS/frontend agents + evidence, observability"
STR_STACK_ML="ML/data agents + evidence, observability, epistemic"
STR_STACK_SYSTEMS="Rust/Go agents + Rust-first + evidence, observability"
STR_STACK_MOBILE="Swift/Flutter agents + evidence, observability"
STR_PACK_INTRO="Optional discipline packs (safety is always on):"
STR_PACK_EVIDENCE="force evidence markers on numeric/cost claims"
STR_PACK_OBS="task timing, session dumps, agent telemetry"
STR_PACK_EPI="no-downgrade + alignment + recurrence reminders"
STR_PACK_ORCH="multi-agent fork logging + orchestrator git checks"
STR_PACK_GIT="block git push to github (for private-remote teams)"
STR_PACK_ENABLE="enable? [y/N]: "

View file

@ -19,7 +19,7 @@ STR_TR_AZURE_OPENAI="Azure OpenAI (deployment+ключ)"
STR_TR_GOOGLE_VERTEX="Google Vertex AI (GCP)" STR_TR_GOOGLE_VERTEX="Google Vertex AI (GCP)"
STR_TR_LOCAL="Локально (Ollama/MLX/LMStudio)" STR_TR_LOCAL="Локально (Ollama/MLX/LMStudio)"
STR_TR_PROXY="Прокси (LiteLLM/OpenRouter)" STR_TR_PROXY="Прокси (LiteLLM/OpenRouter)"
STR_TR_SUBSCRIPTION="Вход по подписке (Claude Code / ChatGPT — без API-ключа)" STR_TR_SUBSCRIPTION="OAuth-подписка (ChatGPT)"
# Сбор ключей # Сбор ключей
STR_AUTH_INTRO="Аутентификация для" STR_AUTH_INTRO="Аутентификация для"
@ -40,9 +40,3 @@ STR_MENU_CONFIRM="Подтвердить выбор?"
# Preflight-предупреждения # Preflight-предупреждения
STR_PREFLIGHT_FAILED="Preflight упал — провайдер может не работать." STR_PREFLIGHT_FAILED="Preflight упал — провайдер может не работать."
STR_PREFLIGHT_CONTINUE="Продолжить всё равно? [y/N]" STR_PREFLIGHT_CONTINUE="Продолжить всё равно? [y/N]"
# Пояснения мастера + валидация ввода
STR_PICK_INVALID="введите один из показанных номеров"
STR_EXPLAIN_TRANSPORT="Как агенты обращаются к ИИ. subscription = вход по подписке, без API-ключа (Claude Code — вариант 1); direct-api = свой API-ключ. Нажми Enter для варианта по умолчанию."
STR_EXPLAIN_PROVIDER="Какой ИИ-сервис. Вариант 1 — рекомендуемый по умолчанию, нажми Enter."
STR_EXPLAIN_MODEL="Модель, которую используют агенты. Вариант 1 — рекомендуемый по умолчанию, нажми Enter."

View file

@ -14,18 +14,9 @@
# when present. # when present.
install_manifests() { install_manifests() {
say "copying generic manifests -> $AGENTS_DIR/_manifests/ (skip if exists)" say "copying generic manifests -> $AGENTS_DIR/_manifests/ (skip if exists)"
# Stack filter: when a stack profile is chosen, install only its agent set. local copied=0 skipped=0 f name t has_templates=0
# Empty allowlist (no stack / non-interactive) => install ALL (back-compat).
local allow=""
if command -v resolve_selected_agent_manifests >/dev/null 2>&1; then
allow="$(resolve_selected_agent_manifests)"
fi
local copied=0 skipped=0 filtered=0 f name t has_templates=0
for f in "$KIT_DIR/_manifests/"*.toml; do for f in "$KIT_DIR/_manifests/"*.toml; do
name="$(basename "$f")" name="$(basename "$f")"
if [ -n "$allow" ] && ! printf '%s\n' "$allow" | grep -qx "${name%.toml}"; then
filtered=$((filtered+1)); continue
fi
if [[ -f "$AGENTS_DIR/_manifests/$name" ]]; then if [[ -f "$AGENTS_DIR/_manifests/$name" ]]; then
skipped=$((skipped+1)) skipped=$((skipped+1))
else else
@ -33,11 +24,7 @@ install_manifests() {
copied=$((copied+1)) copied=$((copied+1))
fi fi
done done
if [ -n "$allow" ]; then say " copied $copied, skipped $skipped (already present)"
say " copied $copied, skipped $skipped, stack-filtered $filtered"
else
say " copied $copied, skipped $skipped (already present)"
fi
for t in "$KIT_DIR/_templates/"*.template; do for t in "$KIT_DIR/_templates/"*.template; do
[ -f "$t" ] && { has_templates=1; break; } [ -f "$t" ] && { has_templates=1; break; }

View file

@ -80,29 +80,14 @@ _mint_runner_token() {
printf '%s' "$token" printf '%s' "$token"
} }
# v0.45 fix: brew installs `gitea-runner` (not `act_runner`); the binary is # Internal: register act_runner with the local Forgejo. Writes ${DATA}/.runner.
# named `gitea-runner`. Resolver tries both names so future brew packaging # Args: <data_dir> <token>.
# changes don't re-break this. act_runner upstream and gitea-runner fork are
# functionally equivalent and both register with Forgejo.
_runner_bin() {
if command -v act_runner >/dev/null 2>&1; then
echo "act_runner"
elif command -v gitea-runner >/dev/null 2>&1; then
echo "gitea-runner"
else
return 1
fi
}
# Internal: register the runner with the local Forgejo. Writes ${DATA}/.runner.
_register_act_runner() { _register_act_runner() {
local data_dir="$1" local data_dir="$1"
local token="$2" local token="$2"
local label="self-hosted,macos-arm64,native" local label="self-hosted,macos-arm64,native"
local name="$(hostname -s)-keisei" local name="$(hostname -s)-keisei"
local runner ( cd "$data_dir" && act_runner register \
runner="$(_runner_bin)" || { err "no runner binary found (looked for act_runner + gitea-runner)"; return 1; }
( cd "$data_dir" && "$runner" register \
--no-interactive \ --no-interactive \
--instance http://127.0.0.1:3001 \ --instance http://127.0.0.1:3001 \
--token "$token" \ --token "$token" \
@ -112,19 +97,12 @@ _register_act_runner() {
# Public entry: install + register + bootstrap the runner. # Public entry: install + register + bootstrap the runner.
install_dev_hub_forgejo_runner() { install_dev_hub_forgejo_runner() {
say "installing dev-hub-forgejo-runner (Forgejo Actions runner)" say "installing dev-hub-forgejo-runner (act_runner)"
_require_forgejo_binary || return 1 _require_forgejo_binary || return 1
_require_forgejo_running || return 1 _require_forgejo_running || return 1
# Prefer the Forgejo-official runner; fall back to the gitea-runner fork say "brew install act_runner"
# (which is what `brew install gitea-runner` actually provides today). brew install act_runner
if ! _runner_bin >/dev/null 2>&1; then
say "brew install gitea-runner (Forgejo-compatible)"
brew install gitea-runner || {
warn "brew install gitea-runner failed — try 'brew tap actions/runner' for act_runner"
return 1
}
fi
local data_dir local data_dir
data_dir="$(_runner_data_dir)" data_dir="$(_runner_data_dir)"
@ -147,9 +125,7 @@ install_dev_hub_forgejo_runner() {
. "$KIT_DIR/install/lib-launchd.sh" . "$KIT_DIR/install/lib-launchd.sh"
install_service forgejo-runner install_service forgejo-runner
local runner_name say "act_runner registered + running. Polling http://127.0.0.1:3001 for jobs."
runner_name="$(_runner_bin 2>/dev/null || echo runner)"
say "$runner_name registered + running. Polling http://127.0.0.1:3001 for jobs."
} }
# Public entry: stop + unload the runner. Keeps ${DATA}/.runner so re-install # Public entry: stop + unload the runner. Keeps ${DATA}/.runner so re-install

View file

@ -97,19 +97,11 @@ _dhf_bootstrap_admin_user() {
local kc_token_svc kc_pass_svc local kc_token_svc kc_pass_svc
config="$(_dhf_app_ini)" config="$(_dhf_app_ini)"
username="${KEI_FORGEJO_ADMIN_USER:-${USER:-denis}}" username="${KEI_FORGEJO_ADMIN_USER:-${USER:-denis}}"
# Single-source Keychain service names (override per-host via env).
# Wizard MUST read identical names — see drive-import-wizard.sh.tmpl.
kc_token_svc="${KEI_FORGEJO_KC_TOKEN_SERVICE:-forgejo-api-token}" kc_token_svc="${KEI_FORGEJO_KC_TOKEN_SERVICE:-forgejo-api-token}"
kc_pass_svc="${KEI_FORGEJO_KC_PASS_SERVICE:-forgejo-admin-password}" kc_pass_svc="${KEI_FORGEJO_KC_PASS_SERVICE:-forgejo-admin-password}"
# Detection: any rows beyond header in `admin user list`?
# v0.45 fix: Forgejo on first install needs `migrate` to create the sqlite
# schema. Without it, `admin user create` fails with "no such table: user"
# (verified bug 2026-05-26 in prod curl|bash test). `migrate` is idempotent
# — safe to re-run.
if ! forgejo --config "$config" migrate 2>/dev/null; then
warn " → forgejo migrate failed; daemon may need restart before admin create"
fi
# Detection: any rows beyond header in `admin user list`? Now safe to
# parse since migrate has ensured the user table exists.
user_count="$(forgejo --config "$config" admin user list 2>/dev/null \ user_count="$(forgejo --config "$config" admin user list 2>/dev/null \
| tail -n +2 | grep -cv '^$' || echo 0)" | tail -n +2 | grep -cv '^$' || echo 0)"
if [ "$user_count" -gt 0 ]; then if [ "$user_count" -gt 0 ]; then
@ -168,8 +160,6 @@ _dhf_bootstrap_admin_user() {
# Public — install entry point. Called from install.sh primitives phase. # Public — install entry point. Called from install.sh primitives phase.
install_dev_hub_forgejo() { install_dev_hub_forgejo() {
say "[dev-hub-forgejo] install starting" say "[dev-hub-forgejo] install starting"
# shellcheck source=./lib-launchd.sh
. "$KIT_DIR/install/lib-launchd.sh" # install_service / detect_brew_prefix (was unsourced → command not found)
_dhf_check_brew || return 1 _dhf_check_brew || return 1
_dhf_brew_install || return 1 _dhf_brew_install || return 1
_dhf_ensure_data_dir || return 1 _dhf_ensure_data_dir || return 1

View file

@ -41,38 +41,13 @@ _dhz_check_go_runtime() {
fi fi
} }
# Step b — install zoekt. Zoekt is NOT in homebrew/core — try tap first, # Step b — brew install zoekt (idempotent).
# then fall back to building from source via Go (if installed). On total
# failure, skip cleanly rather than aborting the whole install.
# v0.45 fix: prior version errored hard ("No formula") and bailed the entire
# dev-hub install. Now degrades gracefully.
_dhz_brew_install() { _dhz_brew_install() {
say "installing zoekt (idempotent)" say "installing zoekt via brew (idempotent)"
if command -v zoekt-webserver >/dev/null 2>&1 && command -v zoekt-index >/dev/null 2>&1; then if ! brew install zoekt; then
say " → zoekt already installed; skipping" err "brew install zoekt failed — see brew log above"
return 0 return 1
fi fi
if brew install zoekt 2>/dev/null; then
say " → installed via brew core"
return 0
fi
if brew install sourcegraph/zoekt/zoekt 2>/dev/null \
|| brew install hyperdiscovery/zoekt/zoekt 2>/dev/null; then
say " → installed via tap"
return 0
fi
if command -v go >/dev/null 2>&1; then
say " → falling back to 'go install' from sourcegraph/zoekt"
if go install github.com/sourcegraph/zoekt/cmd/zoekt-webserver@latest \
&& go install github.com/sourcegraph/zoekt/cmd/zoekt-index@latest; then
say " → installed via go"
return 0
fi
fi
warn "zoekt unavailable: not in brew core/taps + no go fallback."
warn "Skipping zoekt service install. Other dev-hub services continue."
warn "To install later: brew install --HEAD sourcegraph/zoekt/zoekt"
return 2 # signal partial — caller treats as skip, not fatal
} }
# Step c — ensure data dir tree (+ index dir). # Step c — ensure data dir tree (+ index dir).
@ -153,8 +128,6 @@ _dhz_print_banner() {
# Public — install entry point. Called from install.sh primitives phase. # Public — install entry point. Called from install.sh primitives phase.
install_dev_hub_zoekt() { install_dev_hub_zoekt() {
say "[dev-hub-zoekt] install starting" say "[dev-hub-zoekt] install starting"
# shellcheck source=./lib-launchd.sh
. "$KIT_DIR/install/lib-launchd.sh" # install_service / detect_brew_prefix (was unsourced → command not found)
_dhz_check_brew || return 1 _dhz_check_brew || return 1
_dhz_check_go_runtime _dhz_check_go_runtime
_dhz_brew_install || return 1 _dhz_brew_install || return 1

View file

@ -27,16 +27,14 @@ install_hooks() {
say " installed $hook_count hook(s)" say " installed $hook_count hook(s)"
# v0.17 — shared hook library (gate.sh + test-gate.sh) # v0.17 — shared hook library (gate.sh + test-gate.sh)
# v0.40 — also copy *.toml files from _lib/ (policy-chain.toml for safe_tools).
if [ -d "$KIT_DIR/hooks/_lib" ]; then if [ -d "$KIT_DIR/hooks/_lib" ]; then
mkdir -p "$HOOKS_DIR/_lib" mkdir -p "$HOOKS_DIR/_lib"
local lib_count=0 lib_src lib_name local lib_count=0 lib_src lib_name
for lib_src in "$KIT_DIR/hooks/_lib/"*.sh "$KIT_DIR/hooks/_lib/"*.toml; do for lib_src in "$KIT_DIR/hooks/_lib/"*.sh; do
[ -f "$lib_src" ] || continue [ -f "$lib_src" ] || continue
lib_name="$(basename "$lib_src")" lib_name="$(basename "$lib_src")"
cp -f "$lib_src" "$HOOKS_DIR/_lib/$lib_name" cp -f "$lib_src" "$HOOKS_DIR/_lib/$lib_name"
# chmod +x only for shell scripts; .toml stays read-only. chmod +x "$HOOKS_DIR/_lib/$lib_name"
case "$lib_name" in *.sh) chmod +x "$HOOKS_DIR/_lib/$lib_name" ;; esac
lib_count=$((lib_count+1)) lib_count=$((lib_count+1))
done done
say " installed $lib_count hook library file(s) -> $HOOKS_DIR/_lib/" say " installed $lib_count hook library file(s) -> $HOOKS_DIR/_lib/"
@ -62,10 +60,6 @@ _jq_merge_hooks() {
| reduce ($add.hooks | keys[]) as $phase ($orig; | reduce ($add.hooks | keys[]) as $phase ($orig;
.hooks[$phase] = ( .hooks[$phase] = (
((.hooks[$phase] // []) + ($add.hooks[$phase] // [])) ((.hooks[$phase] // []) + ($add.hooks[$phase] // []))
# Normalize null/absent matcher to "" (Claude Code /doctor rejects null;
# pre-kit user hooks often have no matcher field) before group_by so
# null and "" collapse into one group.
| map(.matcher //= "")
| group_by(.matcher) | group_by(.matcher)
| map( | map(
.[0].matcher as $m .[0].matcher as $m
@ -104,64 +98,20 @@ _jq_merge_hooks() {
fi fi
} }
# Write a filtered copy of the snippet keeping only hook entries whose command
# basename is in the newline allowlist (plus the cosmetic pet hooks, always
# kept). Drops emptied matcher groups. Echoes the temp path. Arg: $1 = allowlist.
filter_snippet_by_packs() {
local allow="$1" snippet="$KIT_DIR/settings-snippet.json" tmp
tmp="$(mktemp -t kei-snippet.XXXXXX)"
jq --arg allow "$allow" '
def b: sub("^.*/"; "") | sub("\\.sh$"; "");
def keep($ok; $c): (($c | b) as $x | ($ok | index($x)) != null)
or ($c | test("keisei-pet")) or ($c | test("^CMD="));
($allow | split("\n") | map(select(length > 0))) as $ok
| .hooks |= with_entries(
.value |= ( map(.hooks |= map(select(keep($ok; .command))))
| map(select((.hooks | length) > 0)) )
)
' "$snippet" > "$tmp" || { err "snippet filter failed"; rm -f "$tmp"; return 1; }
printf '%s' "$tmp"
}
# Remove every kit-owned hook entry from an existing settings.json (ownership =
# basename in the full pack universe, plus pet hooks). Foreign hooks survive.
# Lets reconfigure REMOVE deselected hooks (the merge alone is additive-only).
# Args: $1 = target settings.json, $2 = newline list of all kit hook basenames.
prune_kit_hooks() {
local target="$1" universe="$2" tmp
tmp="$(mktemp "$target.XXXXXX")"
jq --arg universe "$universe" '
def b: sub("^.*/"; "") | sub("\\.sh$"; "");
def owned($kit; $c): (($c | b) as $x | ($kit | index($x)) != null)
or ($c | test("keisei-pet")) or ($c | test("^CMD="));
($universe | split("\n") | map(select(length > 0))) as $kit
| .hooks |= with_entries(
.value |= ( map(.hooks |= map(select(owned($kit; .command) | not)))
| map(select((.hooks | length) > 0)) )
)
' "$target" > "$tmp" && mv "$tmp" "$target" || { err "prune failed"; rm -f "$tmp"; return 1; }
}
activate_hooks() { activate_hooks() {
local snippet="$KIT_DIR/settings-snippet.json" local snippet="$KIT_DIR/settings-snippet.json"
local target="$HOME_DIR/.claude/settings.json" local target="$HOME_DIR/.claude/settings.json"
[ -f "$snippet" ] || { warn "no snippet at $snippet"; return 0; } [ -f "$snippet" ] || { warn "no snippet at $snippet"; return 0; }
local allow filtered
allow="$(resolve_selected_hook_basenames)"
filtered="$(filter_snippet_by_packs "$allow")" || return 1
if [ ! -f "$target" ]; then if [ ! -f "$target" ]; then
local tmp local tmp
tmp="$(mktemp "$target.XXXXXX")" tmp="$(mktemp "$target.XXXXXX")"
jq 'del(._comment)' "$filtered" > "$tmp" jq 'del(._comment)' "$snippet" > "$tmp"
mv "$tmp" "$target" mv "$tmp" "$target"
rm -f "$filtered" say "created $target from snippet (no prior settings.json)"
say "created $target from filtered snippet"
return 0 return 0
fi fi
backup_file "$target" backup_file "$target"
prune_kit_hooks "$target" "$(all_pack_basenames)" _jq_merge_hooks "$snippet" "$target"
_jq_merge_hooks "$filtered" "$target"
rm -f "$filtered"
} }
# Flag-or-prompt dispatcher, mirroring the v0.15 behavior: # Flag-or-prompt dispatcher, mirroring the v0.15 behavior:
@ -179,7 +129,7 @@ maybe_activate_hooks() {
elif [ ! -f "$settings_file" ]; then elif [ ! -f "$settings_file" ]; then
say "no existing settings.json; installing snippet" say "no existing settings.json; installing snippet"
activate_hooks && DID_ACTIVATE=1 activate_hooks && DID_ACTIVATE=1
elif [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash elif [ -t 0 ] && [ -t 1 ]; then
if [ "$COLOR" = "1" ]; then if [ "$COLOR" = "1" ]; then
printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] ' printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] '
else else

View file

@ -1,43 +1,21 @@
# shellcheck shell=bash # shellcheck shell=bash
# lib-log.sh — say / warn / err with optional ANSI color + KeiSei banner. # lib-log.sh — say / warn / err with optional ANSI color.
# Honors NO_COLOR (no-color.org). Color is ON when stdout is a TTY OR a # Honors NO_COLOR (no-color.org) and TTY detection on fd 1.
# controlling terminal is reachable via /dev/tty — the latter matters under
# `curl|bash`, where web-install.sh tees stdout (so `-t 1` is false even in an
# interactive session, but the terminal is still there via /dev/tty).
# This `-t 1`-with-/dev/tty test is COLOR detection, NOT an interactivity gate
# (see ~/.claude/rules/tty-interactivity-gate.md) — it pairs with no `-t 0`.
# Sourced by install.sh; no top-level execution. # Sourced by install.sh; no top-level execution.
if { [ -t 1 ] || { : < /dev/tty; } 2>/dev/null; } && [ "${NO_COLOR:-}" = "" ]; then # ANSI on iff stdout is a TTY and NO_COLOR is unset.
if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then
COLOR=1 COLOR=1
else else
COLOR=0 COLOR=0
fi fi
# Brand palette: тёмно-жёлтый (gold) for [install], голубой (sky-blue) for KeiSei.
if [ "$COLOR" = "1" ]; then if [ "$COLOR" = "1" ]; then
KEI_GOLD=$'\033[38;5;178m' # тёмно-жёлтый — install prefix say() { printf '\033[1;36m[install]\033[0m %s\n' "$*"; }
KEI_BLUE=$'\033[38;5;39m' # голубой — logo / primitives
KEI_DIM=$'\033[2m'
KEI_RST=$'\033[0m'
say() { printf '%s[install]%s %s\n' "$KEI_GOLD" "$KEI_RST" "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; } err() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; }
else else
KEI_GOLD= KEI_BLUE= KEI_DIM= KEI_RST=
say() { printf '[install] %s\n' "$*"; } say() { printf '[install] %s\n' "$*"; }
warn() { printf '[warn] %s\n' "$*"; } warn() { printf '[warn] %s\n' "$*"; }
err() { printf '[error] %s\n' "$*" >&2; } err() { printf '[error] %s\n' "$*" >&2; }
fi fi
# KeiSei ASCII banner — голубой logo, shown once at install start.
kei_banner() {
printf '\n'
printf '%s ██╗ ██╗███████╗██╗███████╗███████╗██╗%s\n' "$KEI_BLUE" "$KEI_RST"
printf '%s ██║ ██╔╝██╔════╝██║██╔════╝██╔════╝██║%s\n' "$KEI_BLUE" "$KEI_RST"
printf '%s █████╔╝ █████╗ ██║███████╗█████╗ ██║%s\n' "$KEI_BLUE" "$KEI_RST"
printf '%s ██╔═██╗ ██╔══╝ ██║╚════██║██╔══╝ ██║%s\n' "$KEI_BLUE" "$KEI_RST"
printf '%s ██║ ██╗███████╗██║███████║███████╗██║%s\n' "$KEI_BLUE" "$KEI_RST"
printf '%s ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝%s\n' "$KEI_BLUE" "$KEI_RST"
printf '%s KeiSeiKit · installing%s\n\n' "$KEI_GOLD" "$KEI_RST"
}

View file

@ -17,7 +17,8 @@ menu_should_skip() {
[ -n "$ADD_LIST" ] && return 0 [ -n "$ADD_LIST" ] && return 0
[ -n "$REMOVE_NAME" ] && return 0 [ -n "$REMOVE_NAME" ] && return 0
[ "$LIST_MODE" = "1" ] && return 0 [ "$LIST_MODE" = "1" ] && return 0
[ ! -t 0 ] && return 0 # interactive stdin only; not -t 1 (curl|bash tees stdout) [ ! -t 0 ] && return 0
[ ! -t 1 ] && return 0
return 1 return 1
} }
@ -37,7 +38,7 @@ menu_whiptail_profile() {
"ops" "+ 9 infra tools — provision, ssh-check, firewall-diff" OFF \ "ops" "+ 9 infra tools — provision, ssh-check, firewall-diff" OFF \
"dev" "+ 17 dev tools — kei-migrate, kei-memory, deep-sleep" OFF \ "dev" "+ 17 dev tools — kei-migrate, kei-memory, deep-sleep" OFF \
"mcp" "+ 10 MCP tools — kei-router, kei-sage, kei-auth, kei-pet" OFF \ "mcp" "+ 10 MCP tools — kei-router, kei-sage, kei-auth, kei-pet" OFF \
"cortex" "+ 11 cortex stack — kei-cortex daemon + UI primitives" OFF \ "cortex" "+ 11 cortex stack — kei-cortex daemon + cortex-ui" OFF \
"full" "+ all 62 primitives (~5 min, 380 MB)" OFF \ "full" "+ all 62 primitives (~5 min, 380 MB)" OFF \
"local-mirror" "dev hub: cortex + Forgejo + CI runner (+ 13 prims)" OFF \ "local-mirror" "dev hub: cortex + Forgejo + CI runner (+ 13 prims)" OFF \
"dashboard" "local-mirror + projects-index + Datasette (+ 16 prims)" OFF \ "dashboard" "local-mirror + projects-index + Datasette (+ 16 prims)" OFF \

View file

@ -45,20 +45,12 @@ onboarding_fallback_providers() {
printf "lmstudio-local\tlocal\tLM Studio (local)\t_\n" printf "lmstudio-local\tlocal\tLM Studio (local)\t_\n"
printf "litellm-proxy\tproxy\tLiteLLM proxy (keisei.app)\tKEI_LITELLM_KEY\n" printf "litellm-proxy\tproxy\tLiteLLM proxy (keisei.app)\tKEI_LITELLM_KEY\n"
printf "openrouter\tproxy\tOpenRouter\tOPENROUTER_API_KEY\n" printf "openrouter\tproxy\tOpenRouter\tOPENROUTER_API_KEY\n"
printf "claude-code\tsubscription\tClaude Code (subscription — your claude CLI, no API key)\t_\n"
printf "codex\tsubscription\tOpenAI Codex (ChatGPT OAuth)\t_\n" printf "codex\tsubscription\tOpenAI Codex (ChatGPT OAuth)\t_\n"
} }
# Уникальные транспорты — для первого экрана выбора. # Уникальные транспорты — для первого экрана выбора.
# Claude-Code-native kit → выводим subscription + direct-api ПЕРВЫМИ, чтобы
# рекомендованный путь (Claude Code, опция 1) был дефолтом. Остальные следом.
onboarding_list_transports() { onboarding_list_transports() {
local all; all="$(onboarding_list_providers | awk -F'\t' '{print $2}' | sort -u)" onboarding_list_providers | awk -F'\t' '{print $2}' | sort -u
local t
for t in subscription direct-api; do
printf '%s\n' "$all" | grep -qx "$t" && echo "$t"
done
printf '%s\n' "$all" | grep -vxE 'subscription|direct-api' || true
} }
# Провайдеры внутри транспорта. # Провайдеры внутри транспорта.

View file

@ -39,8 +39,6 @@ language = "$ONBOARDING_LANG"
transport = "$ONBOARDING_TRANSPORT" transport = "$ONBOARDING_TRANSPORT"
provider = "$ONBOARDING_PROVIDER" provider = "$ONBOARDING_PROVIDER"
default_model = "$ONBOARDING_MODEL" default_model = "$ONBOARDING_MODEL"
stack_profile = "$ONBOARDING_STACK"
enabled_packs = "$ONBOARDING_PACKS"
EOF EOF
# Override для kei-model-router (HIGH аудит-1). # Override для kei-model-router (HIGH аудит-1).

View file

@ -12,70 +12,6 @@
# - lib-i18n.sh: STR_* словарь + i18n_available_languages + i18n_load_lang # - lib-i18n.sh: STR_* словарь + i18n_available_languages + i18n_load_lang
# - lib-onboarding-registry.sh: списки провайдеров/моделей # - lib-onboarding-registry.sh: списки провайдеров/моделей
# Read a validated 1-based menu choice. Non-numeric or out-of-range input is
# rejected with a re-prompt instead of crashing: bash arithmetic $((ans-1))
# treats a non-numeric "ans" (e.g. the user typing "claude") as a variable name
# → "unbound variable" under `set -u`. $1=option count, $2=prompt.
# Echoes a number in [1,$1] on stdout; prompts/warnings go to stderr.
_onb_read_choice() {
local max="$1" prompt="$2" ans
while true; do
read -r -p "$prompt" ans
ans="${ans:-1}"
if [[ "$ans" =~ ^[0-9]+$ ]] && [ "$ans" -ge 1 ] && [ "$ans" -le "$max" ]; then
printf '%s' "$ans"; return 0
fi
printf ' ⚠ %s\n' "${STR_PICK_INVALID:-please enter a number from 1 to $max}" >&2
done
}
# Step 6 — pick a stack profile (selects which discipline hooks + agents
# install) then optionally toggle discipline packs the stack does not pull.
# Sets ONBOARDING_STACK + ONBOARDING_PACKS. Reuses _onb_read_choice + stack_packs
# (lib-packs.sh). Default = minimal (safety hooks + core agents only).
onboarding_pick_stack() {
echo "" >&2
printf '%s\n' "${STR_PICK_STACK:-Pick your stack profile (selects hooks + agents):}" >&2
local opts="minimal web ml systems mobile" i=1 o d ans
for o in $opts; do
case "$o" in
minimal) d="${STR_STACK_MINIMAL:-safety hooks + core agents only}" ;;
web) d="${STR_STACK_WEB:-TS/frontend agents + evidence, observability}" ;;
ml) d="${STR_STACK_ML:-ML/data agents + evidence, observability, epistemic}" ;;
systems) d="${STR_STACK_SYSTEMS:-Rust/Go agents + Rust-first + evidence, observability}" ;;
mobile) d="${STR_STACK_MOBILE:-Swift/Flutter agents + evidence, observability}" ;;
esac
printf ' %d) %-8s — %s\n' "$i" "$o" "$d" >&2
i=$((i+1))
done
ans="$(_onb_read_choice 5 "${STR_PICK_STACK_PROMPT:-[1-5, default 1=minimal]: }")"
ONBOARDING_STACK="$(echo "$opts" | cut -d' ' -f"$ans")"
[ -n "$ONBOARDING_STACK" ] || ONBOARDING_STACK="minimal"
# Offer discipline packs the chosen stack does not already enable.
local stackpacks p pd reply
stackpacks=" $(command -v stack_packs >/dev/null 2>&1 && stack_packs "$ONBOARDING_STACK") "
ONBOARDING_PACKS=""
printf '%s\n' "${STR_PACK_INTRO:-Optional discipline packs (safety is always on):}" >&2
for p in evidence observability epistemic orchestration git-guard; do
case "$stackpacks" in *" $p "*) continue ;; esac
case "$p" in
evidence) pd="${STR_PACK_EVIDENCE:-force evidence markers on numeric/cost claims}" ;;
observability) pd="${STR_PACK_OBS:-task timing, session dumps, agent telemetry}" ;;
epistemic) pd="${STR_PACK_EPI:-no-downgrade + alignment + recurrence reminders}" ;;
orchestration) pd="${STR_PACK_ORCH:-multi-agent fork logging + orchestrator git checks}" ;;
git-guard) pd="${STR_PACK_GIT:-block git push to github (for private-remote teams)}" ;;
esac
printf ' + %-13s — %s\n' "$p" "$pd" >&2
read -r -p " ${STR_PACK_ENABLE:-enable? [y/N]: }" reply
case "$reply" in y|Y|yes|YES) ONBOARDING_PACKS="$ONBOARDING_PACKS $p" ;; esac
done
ONBOARDING_PACKS="$(echo "$ONBOARDING_PACKS" | sed 's/^ *//;s/ *$//')"
if command -v say >/dev/null 2>&1; then
say "stack: $ONBOARDING_STACK packs: ${ONBOARDING_PACKS:-(stack defaults only)}"
fi
}
onboarding_pick_language() { onboarding_pick_language() {
local langs local langs
langs="$(i18n_available_languages 2>/dev/null)" langs="$(i18n_available_languages 2>/dev/null)"
@ -104,10 +40,11 @@ onboarding_pick_language() {
while IFS=$'\t' read -r code name; do while IFS=$'\t' read -r code name; do
[ -z "$code" ] && continue [ -z "$code" ] && continue
codes+=("$code") codes+=("$code")
printf " %2d) ${KEI_BLUE:-}%s${KEI_RST:-}${KEI_GOLD:-}%s${KEI_RST:-}\n" "$i" "$code" "$name" >&2 printf " %2d) %s — %s\n" "$i" "$code" "$name" >&2
i=$((i+1)) i=$((i+1))
done <<< "$langs" done <<< "$langs"
ans="$(_onb_read_choice "${#codes[@]}" "[1-${#codes[@]}, default 1=en]: ")" read -r -p "[1-${#codes[@]}, default 1=en]: " ans
ans="${ans:-1}"
ONBOARDING_LANG="${codes[$((ans-1))]:-en}" ONBOARDING_LANG="${codes[$((ans-1))]:-en}"
fi fi
command -v i18n_load_lang >/dev/null 2>&1 && i18n_load_lang "$ONBOARDING_LANG" command -v i18n_load_lang >/dev/null 2>&1 && i18n_load_lang "$ONBOARDING_LANG"
@ -139,7 +76,6 @@ onboarding_pick_transport() {
else else
echo "" >&2 echo "" >&2
echo "$prompt" >&2 echo "$prompt" >&2
echo " ${STR_EXPLAIN_TRANSPORT:-How the agents reach the AI. subscription = log in with your plan (no API key); direct-api = your own API key. Default is fine for most.}" >&2
local i=1 local i=1
declare -a opts=() declare -a opts=()
while IFS= read -r tr; do while IFS= read -r tr; do
@ -147,7 +83,8 @@ onboarding_pick_transport() {
echo " $i) $tr" >&2 echo " $i) $tr" >&2
i=$((i+1)) i=$((i+1))
done <<< "$transports" done <<< "$transports"
ans="$(_onb_read_choice "${#opts[@]}" "[1-${#opts[@]}, default 1]: ")" read -r -p "[1-${#opts[@]}, default 1]: " ans
ans="${ans:-1}"
ONBOARDING_TRANSPORT="${opts[$((ans-1))]:-direct-api}" ONBOARDING_TRANSPORT="${opts[$((ans-1))]:-direct-api}"
fi fi
} }
@ -174,7 +111,6 @@ onboarding_pick_provider() {
else else
echo "" >&2 echo "" >&2
echo "${STR_PICK_PROVIDER:-Provider within} $ONBOARDING_TRANSPORT:" >&2 echo "${STR_PICK_PROVIDER:-Provider within} $ONBOARDING_TRANSPORT:" >&2
echo " ${STR_EXPLAIN_PROVIDER:-Which AI service. Option 1 is the recommended default.}" >&2
declare -a ids=() declare -a ids=()
local i=1 local i=1
while IFS=$'\t' read -r id dn ae; do while IFS=$'\t' read -r id dn ae; do
@ -182,7 +118,8 @@ onboarding_pick_provider() {
echo " $i) $id$dn" >&2 echo " $i) $id$dn" >&2
i=$((i+1)) i=$((i+1))
done <<< "$rows" done <<< "$rows"
ans="$(_onb_read_choice "${#ids[@]}" "[1-${#ids[@]}, default 1]: ")" read -r -p "[1-${#ids[@]}, default 1]: " ans
ans="${ans:-1}"
ONBOARDING_PROVIDER="${ids[$((ans-1))]:-${ids[0]}}" ONBOARDING_PROVIDER="${ids[$((ans-1))]:-${ids[0]}}"
fi fi
} }
@ -198,12 +135,6 @@ onboarding_pick_model() {
local rows; rows=$(onboarding_models_for_provider "$lookup") local rows; rows=$(onboarding_models_for_provider "$lookup")
[ -z "$rows" ] && rows=$(printf "claude-sonnet-4-6\tClaude Sonnet 4.6 (fallback)\n") [ -z "$rows" ] && rows=$(printf "claude-sonnet-4-6\tClaude Sonnet 4.6 (fallback)\n")
# Single model → auto-select, no dead-end prompt (mirrors provider count==1).
if [ "$(printf '%s\n' "$rows" | grep -c .)" = "1" ]; then
ONBOARDING_MODEL=$(printf '%s\n' "$rows" | head -1 | awk -F'\t' '{print $1}')
return
fi
if command -v whiptail >/dev/null 2>&1; then if command -v whiptail >/dev/null 2>&1; then
local args=() local args=()
while IFS=$'\t' read -r id dn; do while IFS=$'\t' read -r id dn; do
@ -215,7 +146,6 @@ onboarding_pick_model() {
else else
echo "" >&2 echo "" >&2
echo "${STR_PICK_MODEL:-Models for} $lookup:" >&2 echo "${STR_PICK_MODEL:-Models for} $lookup:" >&2
echo " ${STR_EXPLAIN_MODEL:-Default model the agents use. Option 1 is the recommended default.}" >&2
declare -a ids=() declare -a ids=()
local i=1 local i=1
while IFS=$'\t' read -r id dn; do while IFS=$'\t' read -r id dn; do
@ -223,7 +153,8 @@ onboarding_pick_model() {
echo " $i) $id$dn" >&2 echo " $i) $id$dn" >&2
i=$((i+1)) i=$((i+1))
done <<< "$rows" done <<< "$rows"
ans="$(_onb_read_choice "${#ids[@]}" "[1-${#ids[@]}, default 1]: ")" read -r -p "[1-${#ids[@]}, default 1]: " ans
ans="${ans:-1}"
ONBOARDING_MODEL="${ids[$((ans-1))]:-${ids[0]}}" ONBOARDING_MODEL="${ids[$((ans-1))]:-${ids[0]}}"
fi fi
} }

View file

@ -20,8 +20,6 @@ ONBOARDING_LANG=""
ONBOARDING_TRANSPORT="" ONBOARDING_TRANSPORT=""
ONBOARDING_PROVIDER="" ONBOARDING_PROVIDER=""
ONBOARDING_MODEL="" ONBOARDING_MODEL=""
ONBOARDING_STACK=""
ONBOARDING_PACKS=""
declare -a ONBOARDING_AUTH_ENV_KEYS=() declare -a ONBOARDING_AUTH_ENV_KEYS=()
declare -a ONBOARDING_AUTH_ENV_VALUES=() declare -a ONBOARDING_AUTH_ENV_VALUES=()
@ -43,36 +41,32 @@ REGISTRY_MODELS="$KIT_DIR/_blocks/registries/models.toml"
onboarding_should_run() { onboarding_should_run() {
[ -f "$ONBOARDED_FLAG" ] && return 1 [ -f "$ONBOARDED_FLAG" ] && return 1
[ "${KEISEI_SKIP_ONBOARD:-}" = "1" ] && return 1 [ "${KEISEI_SKIP_ONBOARD:-}" = "1" ] && return 1
# Interactive iff stdin is a terminal. We deliberately do NOT require -t 1:
# the curl|bash bootstrapper (web-install.sh) tees stdout to a logfile, so
# -t 1 is false even in an interactive session. Prompts go to stderr, input
# reads from stdin — an interactive stdin is the only real requirement.
[ ! -t 0 ] && return 1 [ ! -t 0 ] && return 1
[ ! -t 1 ] && return 1
return 0 return 0
} }
# Оркестратор: 6 шагов + preflight + запись. # Оркестратор: 5 шагов + preflight + запись.
onboarding_run() { onboarding_run() {
onboarding_should_run || return 0 onboarding_should_run || return 0
if command -v say >/dev/null 2>&1; then if command -v say >/dev/null 2>&1; then
say "${STR_ONBOARDING_INTRO:-Onboarding wizard (6 steps)}" say "${STR_ONBOARDING_INTRO:-Onboarding wizard (5 steps)}"
else else
echo "── KeiSei: ${STR_ONBOARDING_INTRO:-onboarding (6 steps)} ──" >&2 echo "── KeiSei: ${STR_ONBOARDING_INTRO:-onboarding (5 steps)} ──" >&2
fi fi
onboarding_pick_language onboarding_pick_language
onboarding_pick_transport onboarding_pick_transport
onboarding_pick_provider onboarding_pick_provider
onboarding_pick_model onboarding_pick_model
onboarding_pick_stack
# Preflight — провайдер-специфичная проверка CLI/daemon до сбора ключей. # Preflight — провайдер-специфичная проверка CLI/daemon до сбора ключей.
if command -v preflight_run >/dev/null 2>&1; then if command -v preflight_run >/dev/null 2>&1; then
if ! preflight_run "$ONBOARDING_PROVIDER"; then if ! preflight_run "$ONBOARDING_PROVIDER"; then
echo "" >&2 echo "" >&2
echo "${STR_PREFLIGHT_FAILED:-Preflight failed — provider may not work.}" >&2 echo "${STR_PREFLIGHT_FAILED:-Preflight failed — provider may not work.}" >&2
if [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash if [ -t 0 ] && [ -t 1 ]; then
read -r -p " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " _ans read -r -p " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " _ans
case "$_ans" in case "$_ans" in
y|Y|yes|да|Да) y|Y|yes|да|Да)
@ -98,9 +92,4 @@ onboarding_run() {
say " ${STR_DONE_CONFIG:-config:} $ONBOARDING_CONFIG" say " ${STR_DONE_CONFIG:-config:} $ONBOARDING_CONFIG"
[ "${#ONBOARDING_AUTH_ENV_KEYS[@]}" -gt 0 ] && say " ${STR_DONE_SECRETS:-secrets:} $SECRETS_ENV (chmod 600)" [ "${#ONBOARDING_AUTH_ENV_KEYS[@]}" -gt 0 ] && say " ${STR_DONE_SECRETS:-secrets:} $SECRETS_ENV (chmod 600)"
fi fi
# MUST end on success: the last statement above is a short-circuit `&&` that
# evaluates false when the provider has no auth keys (claude-code / codex /
# local) → function would return 1 → `set -e` aborts the whole install at the
# onboarding_run call. Subscription/local providers are exactly the no-key case.
return 0
} }

View file

@ -1,74 +0,0 @@
# shellcheck shell=bash
# lib-packs.sh — hook-pack + stack-profile resolver. Reads _primitives/hook-packs.toml
# via the generic _toml_array reader (from lib-profile.sh). Decides which hooks get
# wired into settings.json and which agent manifests install, based on the user's
# onboarding selection (or the safe minimal default when none was made).
#
# Requires: _toml_array from lib-profile.sh.
# Reads globals: $KIT_DIR (kit checkout), optional $ONBOARDING_STACK / $ONBOARDING_PACKS
# (live onboarding), optional $ONBOARDING_CONFIG (persisted selection).
PACKS_TOML="${PACKS_TOML:-$KIT_DIR/_primitives/hook-packs.toml}"
# --- thin table readers ---------------------------------------------------
pack_hooks() { _toml_array "$PACKS_TOML" "pack" "$1"; }
stack_packs() { _toml_array "$PACKS_TOML" "stack-packs" "$1"; }
stack_agent_groups() { _toml_array "$PACKS_TOML" "stack-agents" "$1"; }
agent_set_members() { _toml_array "$PACKS_TOML" "agent-set" "$1"; }
_packs_always() { _toml_array "$PACKS_TOML" "pack-always" "base"; }
# --- selection (live onboarding globals > persisted toml > none) ----------
# Echo the chosen stack, or empty if the user never chose one.
_packs_chosen_stack() {
if [ -n "${ONBOARDING_STACK:-}" ]; then printf '%s' "$ONBOARDING_STACK"; return; fi
local cfg="${ONBOARDING_CONFIG:-$HOME/.claude/config/onboarding.toml}"
[ -f "$cfg" ] && grep -E '^stack_profile[[:space:]]*=' "$cfg" \
| sed -E 's/.*=[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' | head -1
}
# Echo the explicitly enabled packs (space-separated), or empty.
_packs_chosen_packs() {
if [ -n "${ONBOARDING_PACKS:-}" ]; then printf '%s' "$ONBOARDING_PACKS"; return; fi
local cfg="${ONBOARDING_CONFIG:-$HOME/.claude/config/onboarding.toml}"
[ -f "$cfg" ] && grep -E '^enabled_packs[[:space:]]*=' "$cfg" \
| sed -E 's/.*=[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' | head -1
}
# --- resolution -----------------------------------------------------------
# Newline list of hook basenames to wire on install: safety + always + every
# pack pulled by the chosen stack + every explicitly enabled pack. Default
# (no choice) = safety + always only.
resolve_selected_hook_basenames() {
local stack packs p out
stack="$(_packs_chosen_stack)"; stack="${stack:-minimal}"
packs="$(_packs_chosen_packs)"
out="$(pack_hooks safety) $(_packs_always)"
for p in $(stack_packs "$stack") $packs; do
out="$out $(pack_hooks "$p")"
done
echo "$out" | tr ' ' '\n' | grep -v '^$' | sort -u
}
# Newline list of agent manifest basenames to install for the chosen stack
# (base group always included). EMPTY when no stack was chosen → caller
# installs ALL manifests (power-user / non-interactive default).
resolve_selected_agent_manifests() {
local stack g out
stack="$(_packs_chosen_stack)"
[ -n "$stack" ] || return 0
out=""
for g in $(stack_agent_groups "$stack"); do
out="$out $(agent_set_members "$g")"
done
echo "$out" | tr ' ' '\n' | grep -v '^$' | sort -u
}
# Newline list of EVERY kit-owned hook basename (all packs + always). Used by
# prune_kit_hooks to identify which settings.json entries the kit owns.
all_pack_basenames() {
local p out=""
for p in safety evidence observability epistemic orchestration git-guard stack-rust; do
out="$out $(pack_hooks "$p")"
done
out="$out $(_packs_always)"
echo "$out" | tr ' ' '\n' | grep -v '^$' | sort -u
}

View file

@ -100,7 +100,7 @@ _print_primitive_rows() {
[ -z "$name" ] && continue [ -z "$name" ] && continue
kind="$(primitive_field "$name" kind 2>/dev/null || echo '?')" kind="$(primitive_field "$name" kind 2>/dev/null || echo '?')"
extra="$(primitive_time_secs "$name")s, $(( $(primitive_disk_kb "$name") / 1024 )) MB" extra="$(primitive_time_secs "$name")s, $(( $(primitive_disk_kb "$name") / 1024 )) MB"
printf ' + %s%-22s%s (%s, ~%s)\n' "${KEI_BLUE:-}" "$name" "${KEI_RST:-}" "$kind" "$extra" printf ' + %-22s (%s, ~%s)\n' "$name" "$kind" "$extra"
done done
} }

View file

@ -25,7 +25,7 @@ preflight_offer_install() {
echo "$cli не найден." >&2 echo "$cli не найден." >&2
echo " Установить: $install_cmd" >&2 echo " Установить: $install_cmd" >&2
echo "" >&2 echo "" >&2
if [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash if [ -t 0 ] && [ -t 1 ]; then
echo " ⓘ команда: $install_cmd" >&2 echo " ⓘ команда: $install_cmd" >&2
read -r -p " Поставить сейчас? [y/N/skip] " ans read -r -p " Поставить сейчас? [y/N/skip] " ans
case "$ans" in case "$ans" in

View file

@ -28,7 +28,7 @@ copy_shell_primitive() {
mkdir -p "$AGENTS_DIR/_primitives" mkdir -p "$AGENTS_DIR/_primitives"
cp -f "$src" "$dst" cp -f "$src" "$dst"
chmod +x "$dst" chmod +x "$dst"
say " + shell: ${KEI_BLUE:-}$name${KEI_RST:-} ($file)" say " + shell: $name ($file)"
} }
remove_shell_primitive() { remove_shell_primitive() {
@ -65,7 +65,7 @@ copy_rust_primitive() {
cp -rf "$src/$sibling/"* "$dst/$sibling/" 2>/dev/null || true cp -rf "$src/$sibling/"* "$dst/$sibling/" 2>/dev/null || true
fi fi
done done
say " + rust: ${KEI_BLUE:-}$name${KEI_RST:-} (crate $crate)" say " + rust: $name (crate $crate)"
} }
remove_rust_primitive() { remove_rust_primitive() {
@ -103,7 +103,7 @@ copy_node_primitive() {
find "$dst" -depth -name "$ex" -exec rm -rf {} + 2>/dev/null || true find "$dst" -depth -name "$ex" -exec rm -rf {} + 2>/dev/null || true
done < <(_node_excludes) done < <(_node_excludes)
fi fi
say " + node: ${KEI_BLUE:-}$name${KEI_RST:-} (path $rel)" say " + node: $name (path $rel)"
} }
remove_node_primitive() { remove_node_primitive() {
@ -171,7 +171,7 @@ install_external_primitive() {
err "external primitive $name: entry point $slug not found in $file" err "external primitive $name: entry point $slug not found in $file"
return 1 return 1
fi fi
say " + external: ${KEI_BLUE:-}$name${KEI_RST:-} (via $file)" say " + external: $name (via $file)"
"$slug" || warn "$name install failed — re-run after fixing prereqs" "$slug" || warn "$name install failed — re-run after fixing prereqs"
} }

View file

@ -19,16 +19,13 @@ have_python_toml() {
return 1 return 1
} }
# Generic one-line-array TOML reader. Echoes space-separated values of # Echo space-separated primitive names for a given profile.
# [<table>] # Usage: profile_members <profile-name>
# <key> = ["a", "b", ...] profile_members() {
# python-tomllib preferred; awk fallback handles one-line arrays only. local profile="$1"
# Usage: _toml_array <file> <table> <key> [ -f "$MANIFEST" ] || { err "MANIFEST.toml not found at $MANIFEST"; return 1; }
_toml_array() {
local file="$1" table="$2" key="$3"
[ -f "$file" ] || return 1
if have_python_toml; then if have_python_toml; then
python3 - "$file" "$table" "$key" <<'PY' 2>/dev/null || return 1 python3 - "$MANIFEST" "$profile" <<'PY' 2>/dev/null || return 1
import sys import sys
try: try:
import tomllib import tomllib
@ -36,19 +33,20 @@ try:
except ImportError: except ImportError:
import toml as tomllib import toml as tomllib
mode = "r" mode = "r"
path, table, key = sys.argv[1], sys.argv[2], sys.argv[3] path, prof = sys.argv[1], sys.argv[2]
with open(path, mode) as f: with open(path, mode) as f:
data = tomllib.load(f) data = tomllib.load(f) if mode == "rb" else tomllib.load(f)
vals = data.get(table, {}).get(key) members = data.get("profile", {}).get(prof)
if vals is None: if members is None:
sys.exit(2) sys.exit(2)
print(" ".join(vals)) print(" ".join(members))
PY PY
else else
awk -v table="$table" -v key="$key" ' # awk fallback — only handles `profile.<name> = [...]` on one line
$0 ~ "^\\[" table "\\]" { in_t=1; next } awk -v prof="$profile" '
/^\[/ { in_t=0 } /^\[profile\]/ { in_profile=1; next }
in_t && $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { /^\[/ && !/^\[profile\]/ { in_profile=0 }
in_profile && $0 ~ "^[[:space:]]*" prof "[[:space:]]*=" {
line = $0 line = $0
sub(/^[^\[]*\[/, "", line) sub(/^[^\[]*\[/, "", line)
sub(/\].*$/, "", line) sub(/\].*$/, "", line)
@ -57,18 +55,10 @@ PY
print line print line
exit exit
} }
' "$file" ' "$MANIFEST"
fi fi
} }
# Echo space-separated primitive names for a given profile.
# Usage: profile_members <profile-name>
profile_members() {
local profile="$1"
[ -f "$MANIFEST" ] || { err "MANIFEST.toml not found at $MANIFEST"; return 1; }
_toml_array "$MANIFEST" "profile" "$profile"
}
# Echo a field of a primitive. Usage: primitive_field <name> <field> # Echo a field of a primitive. Usage: primitive_field <name> <field>
# field ∈ { kind, file, crate, desc, deps } # field ∈ { kind, file, crate, desc, deps }
primitive_field() { primitive_field() {

View file

@ -66,19 +66,20 @@ copy_sleep_scripts() {
fi fi
} }
# Pure-bash scripts → ~/.claude/scripts/ (tamagotchi renderer + state updater, # KeiSei tamagotchi statusline — copy the renderer + state updater into
# kei-message mailbox CLI, any future scripts). Zero binary deps, always # ~/.claude/scripts/. Zero binary deps (pure bash, state under ~/.claude/pet/),
# available regardless of profile. statusLine + pet-update + mailbox-inject # always available regardless of profile. The statusLine + pet-update hooks
# hooks are wired into settings.json by the settings-snippet merge (lib-hooks.sh). # are wired into settings.json by the settings-snippet merge (lib-hooks.sh).
copy_pet_scripts() { copy_pet_scripts() {
local src dst="$HOME_DIR/.claude/scripts" name local pet_sh src dst="$HOME_DIR/.claude/scripts"
[ -d "$KIT_DIR/scripts" ] || return 0 [ -d "$KIT_DIR/scripts" ] || return 0
mkdir -p "$dst" mkdir -p "$dst"
for src in "$KIT_DIR/scripts/"*.sh; do for pet_sh in keisei-pet.sh keisei-pet-update.sh; do
[ -f "$src" ] || continue src="$KIT_DIR/scripts/$pet_sh"
name="$(basename "$src")" if [ -f "$src" ]; then
cp -f "$src" "$dst/$name" cp -f "$src" "$dst/$pet_sh"
chmod +x "$dst/$name" chmod +x "$dst/$pet_sh"
fi
done done
} }

View file

@ -66,9 +66,7 @@ print_summary() {
$AGENTS_DIR/_assembler/target/release/assemble --validate $AGENTS_DIR/_assembler/target/release/assemble --validate
./install.sh --list # show installed primitives ./install.sh --list # show installed primitives
To set up agents for ALL your projects (scan stack + create one per project): To create a new project-specialist agent:
/onboard ~/Projects/*
Or create a single project-specialist agent:
/new-agent /new-agent
========================================================================== ==========================================================================
@ -95,9 +93,7 @@ EOF
$AGENTS_DIR/_assembler/target/release/assemble --validate $AGENTS_DIR/_assembler/target/release/assemble --validate
./install.sh --list # show installed primitives ./install.sh --list # show installed primitives
To set up agents for ALL your projects (scan stack + create one per project): To create a new project-specialist agent:
/onboard ~/Projects/*
Or create a single project-specialist agent:
/new-agent /new-agent
========================================================================== ==========================================================================

View file

@ -10,7 +10,7 @@
run_sleep_wizard() { run_sleep_wizard() {
local sleep_helper="$AGENTS_DIR/_primitives/kei-sleep-setup.sh" local sleep_helper="$AGENTS_DIR/_primitives/kei-sleep-setup.sh"
if [[ -x "$sleep_helper" ]] && [ -t 0 ]; then # stdin only; not -t 1 (curl|bash tees stdout) if [[ -x "$sleep_helper" ]] && [ -t 0 ] && [ -t 1 ]; then
say "running sleep-sync setup helper" say "running sleep-sync setup helper"
"$sleep_helper" || warn "sleep-sync setup did not complete — re-run via /sleep-setup" "$sleep_helper" || warn "sleep-sync setup did not complete — re-run via /sleep-setup"
else else

View file

@ -2,10 +2,10 @@
"$schema": "https://json.schemastore.org/claude-code-plugin.json", "$schema": "https://json.schemastore.org/claude-code-plugin.json",
"name": "keisei", "name": "keisei",
"displayName": "KeiSei", "displayName": "KeiSei",
"description": "Constructor Pattern multi-LLM agent substrate — 38 agents, 69 skills, 54 hooks, 86 blocks. Cross-CLI policy enforcement (Claude/Grok/Copilot/Agy/Kimi) via kei-mcp + kei_bash/kei_edit/kei_write. Rust primitives via classic ./install.sh.", "description": "Constructor Pattern agent substrate — 59 agents, 67 skills, 39 hooks, 86 blocks. Rust primitives via classic ./install.sh.",
"version": "0.45.0", "version": "0.38.0",
"homepage": "https://keisei.app", "homepage": "https://keigit.com/keisei/KeiSeiKit-1.0",
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git", "repository": "https://keigit.com/keisei/KeiSeiKit-1.0.git",
"author": { "author": {
"name": "Denis Parfionovich", "name": "Denis Parfionovich",
"email": "parfionovich@keilab.io" "email": "parfionovich@keilab.io"

View file

@ -1,281 +0,0 @@
#!/usr/bin/env bash
# kei-agent-cli — invoke a KeiSeiKit agent via an external LLM CLI backend.
#
# Two entry points (both route through this script):
#
# kei run-via <backend> <agent> "<task>" # explicit backend
# kei agent <agent> "<task>" # backend resolved from DNA:
# # 1. --on=<backend> flag
# # 2. agent manifest's `provider`
# # 3. ~/.claude/config/primary.toml
# # 4. fallback: claude
#
# Other forms:
# kei run-via list # show backends + agents
# kei agent --on=<backend> <agent> "<task>" # override DNA backend
# kei primary # print current primary
# kei primary <backend> # set primary provider
# kei run-via --help
#
# Backends (SSoT: _primitives/cli-backends.toml):
# claude grok agy copilot kimi codex
#
# Reads assembled prompt from ~/.claude/agents/<agent-name>.md.
# Strips YAML frontmatter, composes with task, execs the CLI.
#
# Env overrides:
# KEI_AGENTS_DIR agent .md dir (default: ~/.claude/agents)
# KEI_MANIFESTS_DIR manifest .toml dir (default: ~/.claude/_manifests)
# KEI_PRIMARY override primary backend (beats config file)
# KEI_NATIVE_AGENT=1 prefer backend's native --agent flag (grok/claude)
set -euo pipefail
KEI_AGENTS_DIR="${KEI_AGENTS_DIR:-$HOME/.claude/agents}"
KEI_MANIFESTS_DIR="${KEI_MANIFESTS_DIR:-$HOME/.claude/_manifests}"
KEI_PRIMARY_CFG="${KEI_PRIMARY_CFG:-$HOME/.claude/config/primary.toml}"
KEI_NATIVE_AGENT="${KEI_NATIVE_AGENT:-0}"
usage() { sed -n '2,32p' "$0" | sed 's|^# \{0,1\}||'; }
# ---- backend table (SSoT mirror; kept in sync with cli-backends.toml) -----
backend_bin() {
case "$1" in
claude) echo "claude" ;;
grok) echo "grok" ;;
agy|antigravity) echo "agy" ;;
copilot) echo "copilot" ;;
kimi) echo "kimi" ;;
codex) echo "codex" ;;
*) return 1 ;;
esac
}
backend_supports_native_agent() {
case "$1" in claude|grok) return 0 ;; *) return 1 ;; esac
}
# ---- DNA resolver: agent → preferred backend --------------------------------
# Reads `provider = "..."` line from the manifest TOML if present.
manifest_provider() {
local agent="$1" tomlf="$KEI_MANIFESTS_DIR/$1.toml"
[ -f "$tomlf" ] || return 1
awk -F'=' '
/^provider[[:space:]]*=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
gsub(/^"|"$/, "", $2)
print $2; exit
}
' "$tomlf"
}
# Reads primary from config file (or KEI_PRIMARY env override).
config_primary() {
if [ -n "${KEI_PRIMARY:-}" ]; then
printf '%s\n' "$KEI_PRIMARY"; return 0
fi
[ -f "$KEI_PRIMARY_CFG" ] || return 1
awk -F'=' '
/^provider[[:space:]]*=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
gsub(/^"|"$/, "", $2)
print $2; exit
}
' "$KEI_PRIMARY_CFG"
}
# Resolution order: explicit --on= → manifest provider → primary → claude.
resolve_backend() {
local agent="$1" explicit="${2:-}" out=""
if [ -n "$explicit" ]; then printf '%s\n' "$explicit"; return 0; fi
out=$(manifest_provider "$agent" 2>/dev/null) || true
if [ -n "$out" ]; then printf '%s\n' "$out"; return 0; fi
out=$(config_primary 2>/dev/null) || true
if [ -n "$out" ]; then printf '%s\n' "$out"; return 0; fi
printf 'claude\n'
}
# ---- backend invocation ---------------------------------------------------
backend_invoke() {
local backend="$1" prompt="$2" agent_name="${3:-}" bin
bin=$(backend_bin "$backend") || {
printf '[kei-agent-cli] unknown backend: %s\n' "$backend" >&2
return 2
}
command -v "$bin" >/dev/null 2>&1 || {
printf '[kei-agent-cli] %s not on PATH. Install it or pick another backend.\n' "$bin" >&2
return 127
}
# Native --agent path (grok/claude) — pass agent name + task directly.
if [ "$KEI_NATIVE_AGENT" = "1" ] \
&& [ -n "$agent_name" ] \
&& backend_supports_native_agent "$backend"; then
printf '[kei-agent-cli] %s --agent %s\n' "$bin" "$agent_name" >&2
exec "$bin" --agent "$agent_name" --print "${prompt##*TASK FOR THIS RUN:}"
fi
# v0.41 fix: headless subprocess invocation of claude/grok without
# --dangerously-skip-permissions returns empty (the agent's system prompt
# asks for Read/Grep tools, but those need permission prompts which can't
# be answered in -p mode). Pass the flag so the agent actually executes.
# Override via KEI_AGENT_PERMISSIVE=0 to keep the strict default.
local permissive_claude="" permissive_grok=""
if [ "${KEI_AGENT_PERMISSIVE:-1}" = "1" ]; then
permissive_claude="--permission-mode=bypassPermissions"
permissive_grok="--always-approve"
fi
case "$backend" in
claude) exec "$bin" $permissive_claude -p "$prompt" ;;
grok) exec "$bin" $permissive_grok --print "$prompt" ;;
agy|antigravity) exec "$bin" --dangerously-skip-permissions --print "$prompt" ;;
copilot) exec "$bin" --prompt "$prompt" ;;
kimi)
# Kimi has NO one-shot print mode (smoke-tested 2026-05-26): bare `kimi`
# opens an interactive TUI that ignores piped stdin and exits with "Bye!".
# For headless invocation we'd need an ACP client (`kimi acp` is a JSON-RPC
# stdio server). Until KeiSeiKit ships that client, dump the composed
# prompt to a tmpfile and open the TUI so the user can paste it in.
tmp=$(mktemp -t kei-agent-kimi.XXXX.md)
printf '%s\n' "$prompt" > "$tmp"
printf '[kei-agent-cli] kimi non-interactive is unsupported (TUI only).\n' >&2
printf '[kei-agent-cli] composed prompt saved: %s\n' "$tmp" >&2
printf '[kei-agent-cli] copy-paste it into Kimi after the TUI opens.\n' >&2
printf '[kei-agent-cli] (or pipe via `kimi acp` if you have an ACP client.)\n' >&2
exec "$bin"
;;
codex) exec "$bin" -p "$prompt" ;;
esac
}
# ---- agent loader -------------------------------------------------------
load_agent() {
local name="$1" path
case "$name" in
--file=*) path="${name#--file=}" ;;
/*|./*|*/*) path="$name" ;;
*) path="$KEI_AGENTS_DIR/$name.md" ;;
esac
if [ ! -f "$path" ]; then
printf '[kei-agent-cli] agent not found: %s\n' "$path" >&2
if [ -d "$KEI_AGENTS_DIR" ]; then
printf ' Available (%s): %s\n' "$KEI_AGENTS_DIR" \
"$(find "$KEI_AGENTS_DIR" -maxdepth 1 -name '*.md' -not -name '_*' 2>/dev/null \
| xargs -n1 basename 2>/dev/null | sed 's/\.md$//' \
| sort | head -8 | tr '\n' ' ')..." >&2
fi
return 1
fi
awk '
BEGIN { in_fm=0 }
NR==1 && /^---$/ { in_fm=1; next }
in_fm && /^---$/ { in_fm=0; next }
in_fm { next }
{ print }
' "$path"
}
# ---- primary subcommand ------------------------------------------------
handle_primary() {
local arg="${1:-}"
if [ -z "$arg" ]; then
cur=$(config_primary 2>/dev/null || true)
printf 'primary provider: %s\n' "${cur:-claude (default fallback)}"
[ -f "$KEI_PRIMARY_CFG" ] && printf 'config: %s\n' "$KEI_PRIMARY_CFG"
return 0
fi
backend_bin "$arg" >/dev/null || {
printf '[kei-primary] unknown backend: %s\n' "$arg" >&2
printf 'valid: claude grok agy copilot kimi codex\n' >&2
return 2
}
mkdir -p "$(dirname "$KEI_PRIMARY_CFG")"
printf '# kei primary — written %s\nprovider = "%s"\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$arg" > "$KEI_PRIMARY_CFG"
printf 'primary provider set: %s → %s\n' "$arg" "$KEI_PRIMARY_CFG"
}
# ---- subcommands --------------------------------------------------------
case "${1:-}" in
""|-h|--help|help) usage; exit 0 ;;
list|--list)
printf 'Backends (✓ installed, ✗ missing):\n'
for b in claude grok agy copilot kimi codex; do
bin=$(backend_bin "$b")
if p=$(command -v "$bin" 2>/dev/null); then
printf ' %-10s ✓ %s\n' "$b" "$p"
else
printf ' %-10s ✗ (not on PATH)\n' "$b"
fi
done
cur=$(config_primary 2>/dev/null || true)
printf '\nprimary: %s\n' "${cur:-claude (default)}"
printf '\nAgents (%s):\n' "$KEI_AGENTS_DIR"
if [ -d "$KEI_AGENTS_DIR" ]; then
find "$KEI_AGENTS_DIR" -maxdepth 1 -name '*.md' -not -name '_*' 2>/dev/null \
| xargs -n1 basename 2>/dev/null | sed 's/\.md$/ /' | sort | column 2>/dev/null \
|| (find "$KEI_AGENTS_DIR" -maxdepth 1 -name '*.md' -not -name '_*' \
| xargs -n1 basename | sed 's/\.md$//' | sort | head -20)
fi
exit 0
;;
primary)
shift
handle_primary "${1:-}"
exit $?
;;
agent)
# Direct-invocation passthrough: `kei-agent-cli.sh agent <name> "task"`
# behaves identically to `kei-agent-cli.sh <name> "task"` (DNA mode).
# Lets users call either form without surprise.
shift
;;
esac
# ---- main: DNA mode (no leading backend) OR explicit run-via ------------
# Detect call shape:
# "$1" is a known backend → run-via flow (kei run-via <backend> <agent> "task")
# "$1" starts with --on= → DNA flow with override
# "$1" is anything else → DNA flow (kei agent <agent> "task")
EXPLICIT_BACKEND=""
case "${1:-}" in
--on=*)
EXPLICIT_BACKEND="${1#--on=}"
shift
;;
*)
if [ $# -ge 1 ] && backend_bin "$1" >/dev/null 2>&1; then
EXPLICIT_BACKEND="$1"
shift
fi
;;
esac
if [ $# -lt 2 ]; then
usage
exit 2
fi
AGENT_REF="$1"; shift
TASK="$*"
AGENT_NAME=$(basename "${AGENT_REF#--file=}")
AGENT_NAME="${AGENT_NAME%.md}"
BACKEND=$(resolve_backend "$AGENT_NAME" "$EXPLICIT_BACKEND")
if ! AGENT_PROMPT=$(load_agent "$AGENT_REF"); then
exit 1
fi
COMPOSED=$(printf '%s\n\n---\n\nTASK FOR THIS RUN:\n%s\n' "$AGENT_PROMPT" "$TASK")
printf '[kei-agent-cli] agent=%s backend=%s (via %s)\n' \
"$AGENT_NAME" "$BACKEND" \
"$([ -n "$EXPLICIT_BACKEND" ] && echo explicit \
|| ([ -n "$(manifest_provider "$AGENT_NAME" 2>/dev/null)" ] && echo manifest \
|| ([ -n "$(config_primary 2>/dev/null)" ] && echo primary || echo default)))" >&2
backend_invoke "$BACKEND" "$COMPOSED" "$AGENT_NAME"

View file

@ -1,62 +0,0 @@
#!/usr/bin/env bash
# kei-configure — re-pick hook packs + stack profile after install, without a
# full reinstall. Updates ~/.claude/config/onboarding.toml and re-applies the
# hook selection to settings.json (adds newly selected hooks, removes deselected
# kit hooks, leaves your own hooks untouched). Agent-set changes apply on the
# next `./install.sh`.
#
# Invoked via `kei configure`. Interactive (needs a terminal).
set -u
set -o pipefail 2>/dev/null || true
HOME_DIR="${HOME:?HOME not set}"
KIT_DIR="$(cat "$HOME_DIR/.claude/.kei-kit-dir" 2>/dev/null || true)"
if [ -z "$KIT_DIR" ] || [ ! -d "$KIT_DIR/install" ]; then
echo "kei configure: KeiSeiKit checkout not found." >&2
echo " (expected its path in ~/.claude/.kei-kit-dir; re-run ./install.sh from your checkout)" >&2
exit 1
fi
if [ ! -t 0 ]; then
echo "kei configure: interactive only — run it from a terminal." >&2
exit 1
fi
LIB_DIR="$KIT_DIR/install"
MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml"
PACKS_TOML="$KIT_DIR/_primitives/hook-packs.toml"
ONBOARDING_CONFIG="$HOME_DIR/.claude/config/onboarding.toml"
export HOME_DIR KIT_DIR LIB_DIR MANIFEST PACKS_TOML ONBOARDING_CONFIG
# shellcheck source=/dev/null
source "$LIB_DIR/lib-log.sh"
# shellcheck source=/dev/null
source "$LIB_DIR/lib-backup.sh"
# shellcheck source=/dev/null
source "$LIB_DIR/lib-profile.sh"
# shellcheck source=/dev/null
source "$LIB_DIR/lib-packs.sh"
# shellcheck source=/dev/null
source "$LIB_DIR/lib-hooks.sh"
# shellcheck source=/dev/null
source "$LIB_DIR/lib-onboarding-ui.sh"
ONBOARDING_STACK=""
ONBOARDING_PACKS=""
onboarding_pick_stack
# Update only stack_profile/enabled_packs in onboarding.toml; preserve the rest.
mkdir -p "$(dirname "$ONBOARDING_CONFIG")"
touch "$ONBOARDING_CONFIG"
_tmp="$(mktemp)"
grep -vE '^(stack_profile|enabled_packs)[[:space:]]*=' "$ONBOARDING_CONFIG" > "$_tmp" 2>/dev/null || true
{
printf 'stack_profile = "%s"\n' "$ONBOARDING_STACK"
printf 'enabled_packs = "%s"\n' "$ONBOARDING_PACKS"
} >> "$_tmp"
mv "$_tmp" "$ONBOARDING_CONFIG"
# Re-apply hooks: prune kit-owned entries, merge the newly selected set.
activate_hooks
say "reconfigured: stack=$ONBOARDING_STACK packs=${ONBOARDING_PACKS:-none}"
say " settings.json hooks updated. Agent-set changes apply on the next ./install.sh."

View file

@ -1,230 +0,0 @@
#!/usr/bin/env bash
# kei-limits — probe each installed CLI's remaining quota / balance.
#
# Reality (research 2026-05-26):
# • claude — no programmatic API. Headers per-API-call only. Admin API
# exists but needs a separate admin key. See dashboard.
# • grok — same as claude. Headers per-API-call only. No file.
# • agy — interactive /usage slash-cmd is broken (shows 100% always,
# forum-verified bug). No public API.
# • copilot — no public quota API. github.com/settings/billing only.
# Inline output during call shows usage but nothing exposed
# for poll.
# • kimi — Moonshot API /v1/users/me/balance returns $ balance only
# (no session/weekly quota). Requires MOONSHOT_API_KEY.
#
# Output:
# stdout: human summary (default) OR JSON (--json)
# file: ~/.claude/pet/limits-cache.json (always, for pet to read)
#
# Polling: NOT poll-friendly. Run on demand or via launchd at >5 min intervals.
# Pet's job: read the cache; pet does NOT call this script.
set -u
# v0.43-fix #4: jq runtime guard (convention with 40+ sibling scripts).
command -v jq >/dev/null 2>&1 || {
echo "kei-limits: jq required (brew install jq / apt install jq)" >&2
exit 1
}
CACHE="${KEI_LIMITS_CACHE:-$HOME/.claude/pet/limits-cache.json}"
mkdir -p "$(dirname "$CACHE")"
JSON_OUT=0
QUIET=0
for arg in "$@"; do
case "$arg" in
--json) JSON_OUT=1 ;;
--quiet) QUIET=1 ;;
-h|--help) sed -n '2,22p' "$0" | sed 's|^# \{0,1\}||'; exit 0 ;;
esac
done
# --- per-CLI probes (each returns one JSON value to stdout) ----------------
probe_claude() {
# No public API; produce a status marker, no live data.
printf '%s' '{"status":"no-api","note":"see claude.ai/settings/usage","dashboard":"https://claude.ai/settings/usage"}'
}
probe_grok() {
printf '%s' '{"status":"no-api","note":"headers-only per API call; see x.ai dashboard","dashboard":"https://x.ai"}'
}
probe_agy() {
printf '%s' '{"status":"broken-api","note":"interactive /usage shows 100% (forum-verified bug); use Google Cloud Console","dashboard":"https://console.cloud.google.com/apis/api/generativelanguage.googleapis.com/quotas"}'
}
probe_copilot() {
# Try gh CLI graphQL — most variants don't expose Copilot billing publicly.
# If we ever find an endpoint, drop it in here. For now: status marker.
printf '%s' '{"status":"no-api","note":"see github.com/settings/billing → Copilot section","dashboard":"https://github.com/settings/billing"}'
}
probe_kimi() {
if [ -z "${MOONSHOT_API_KEY:-}" ]; then
printf '%s' '{"status":"need-key","note":"set MOONSHOT_API_KEY in env to fetch live balance","dashboard":"https://platform.kimi.ai"}'
return
fi
if ! command -v curl >/dev/null 2>&1; then
printf '%s' '{"status":"no-curl","note":"curl required for live probe"}'
return
fi
# v0.44 fix #3 (Gemini HIGH): sanitize MOONSHOT_API_KEY before formatting.
# Was: token injected into a curl --config line via printf 'header = "...%s..."';
# if the token contained a double-quote + newline + 'url = "attacker"',
# curl would parse the injected config option and redirect the request.
# Now: validate the key matches a known-safe charset; reject otherwise.
case "$MOONSHOT_API_KEY" in
*[!A-Za-z0-9_.\-]*)
printf '%s' '{"status":"probe-failed","note":"MOONSHOT_API_KEY contains unsafe chars; expected [A-Za-z0-9_.-]"}'
return
;;
esac
local resp
resp=$(printf 'header = "Authorization: Bearer %s"\n' "$MOONSHOT_API_KEY" \
| curl -sS --max-time 5 --config - \
"https://api.moonshot.ai/v1/users/me/balance" 2>/dev/null \
|| echo '')
if [ -z "$resp" ]; then
printf '%s' '{"status":"probe-failed","note":"no response (network / wrong key)"}'
return
fi
# v0.43-fix #2: tonumber? swallows parse errors (was: tonumber threw on
# any non-numeric balance, emitted empty JSON, poisoned the assembler
# --argjson → whole cache wiped).
local avail
avail=$(printf '%s' "$resp" | jq -r '.data.available_balance // empty' 2>/dev/null)
if [ -z "$avail" ]; then
printf '%s' '{"status":"probe-failed","note":"API returned non-balance response"}'
return
fi
local cash voucher
cash=$(printf '%s' "$resp" | jq -r '.data.cash_balance // 0' 2>/dev/null)
voucher=$(printf '%s' "$resp" | jq -r '.data.voucher_balance // 0' 2>/dev/null)
jq -n --arg s "live" --arg a "$avail" --arg c "$cash" --arg v "$voucher" \
'{status:$s, available_balance_usd:($a|tonumber? // 0), cash_balance_usd:($c|tonumber? // 0), voucher_balance_usd:($v|tonumber? // 0), dashboard:"https://platform.kimi.ai"}'
}
# --- assemble cache JSON ---------------------------------------------------
# v0.43-fix #1: atomic stage-and-rename. Was: `jq > "$CACHE"` truncated the
# cache BEFORE jq ran — a transient failure permanently wiped the cache.
# Now: build in tmpfile, validate non-empty, then atomic mv. Preserves
# last-known-good across probe failures.
# v0.43-fix #2 (defense-in-depth): if any individual probe returns empty
# string, substitute a status marker so --argjson never sees invalid JSON.
_safe_json() {
local payload="$1"
if [ -z "$payload" ]; then
printf '%s' '{"status":"probe-empty","note":"probe returned empty result"}'
return
fi
# Validate parses.
if ! printf '%s' "$payload" | jq empty 2>/dev/null; then
printf '%s' '{"status":"probe-invalid","note":"probe returned non-JSON"}'
return
fi
printf '%s' "$payload"
}
P_CLAUDE=$(_safe_json "$(probe_claude)")
P_GROK=$(_safe_json "$(probe_grok)")
P_AGY=$(_safe_json "$(probe_agy)")
P_COPILOT=$(_safe_json "$(probe_copilot)")
P_KIMI=$(_safe_json "$(probe_kimi)")
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TMP=$(mktemp "${CACHE}.XXXXXX")
if jq -n \
--arg ts "$NOW" \
--argjson claude "$P_CLAUDE" \
--argjson grok "$P_GROK" \
--argjson agy "$P_AGY" \
--argjson copilot "$P_COPILOT" \
--argjson kimi "$P_KIMI" \
'{ts:$ts, claude:$claude, grok:$grok, agy:$agy, copilot:$copilot, kimi:$kimi}' \
> "$TMP" 2>/dev/null \
&& [ -s "$TMP" ]; then
mv -f "$TMP" "$CACHE"
else
rm -f "$TMP" 2>/dev/null
echo "kei-limits: cache refresh failed — keeping previous cache" >&2
if [ ! -f "$CACHE" ]; then
# v0.44 fix #9 (Claude MED): failure-fallback must carry the SAME schema
# as the success cache (ts + 5 per-CLI keys). Was: emitted only {ts,
# status} which broke pet's .kimi.available_balance_usd read and the
# script's own per-CLI render loop. Now: full shape, all 5 marked
# status="assembly-failed".
jq -n '{ts:"",
claude:{status:"assembly-failed",note:"see logs"},
grok:{status:"assembly-failed",note:"see logs"},
agy:{status:"assembly-failed",note:"see logs"},
copilot:{status:"assembly-failed",note:"see logs"},
kimi:{status:"assembly-failed",note:"see logs"}}' \
> "$CACHE" 2>/dev/null \
|| printf '%s\n' '{"ts":"","claude":{"status":"assembly-failed"},"grok":{"status":"assembly-failed"},"agy":{"status":"assembly-failed"},"copilot":{"status":"assembly-failed"},"kimi":{"status":"assembly-failed"}}' > "$CACHE"
fi
fi
# --- output ----------------------------------------------------------------
if [ "$JSON_OUT" = "1" ]; then
cat "$CACHE"
exit 0
fi
if [ "$QUIET" = "1" ]; then
exit 0
fi
C0= CB= CG= CY= CR= CD=
if [ -t 1 ]; then
C0=$'\033[0m'
CB=$'\033[1;38;5;39m'
CG=$'\033[32m'
CY=$'\033[33m'
CR=$'\033[31m'
CD=$'\033[2m'
fi
format_one() {
local label="$1" key="$2" data="$3"
local status note
status=$(printf '%s' "$data" | jq -r '.status')
note=$(printf '%s' "$data" | jq -r '.note // ""')
case "$status" in
live)
local avail
avail=$(printf '%s' "$data" | jq -r '.available_balance_usd // empty')
printf " ${CG}${C0} %-8s \$%-8s ${CD}live (Moonshot balance)${C0}\n" "$label" "$avail"
;;
no-api|need-key)
printf " ${CY}?${C0} %-8s ${CD}%s${C0}\n" "$label" "$note"
;;
broken-api)
printf " ${CR}${C0} %-8s ${CD}%s${C0}\n" "$label" "$note"
;;
*)
printf " ${CY}?${C0} %-8s ${CD}%s${C0}\n" "$label" "$note"
;;
esac
}
cat <<EOF
${CB}╔════════════════════════════════════════════════════════════╗
║ KeiSeiKit · CLI subscription limits ║
╚════════════════════════════════════════════════════════════╝${C0}
EOF
CACHE_CONTENT=$(cat "$CACHE")
for cli in claude grok agy copilot kimi; do
data=$(printf '%s' "$CACHE_CONTENT" | jq -c ".$cli")
format_one "$cli" "$cli" "$data"
done
echo
echo "${CD}cached: $CACHE${C0}"
echo "${CD}note: no CLI exposes session/weekly quota in a poll-friendly way.${C0}"
echo "${CD} See dashboards via 'open <url>' from --json output.${C0}"

View file

@ -1,52 +0,0 @@
#!/usr/bin/env bash
# kei-mcp-wire-agy — TIER 3: advisory enforcement for Google Antigravity.
#
# Antigravity (Gemini-backed) has NO tool allowlist mechanism — only the
# binary --dangerously-skip-permissions flag. We CANNOT disable its native
# shell. Best we can do:
# 1. Register kei-mcp via ~/.gemini/config/mcp_config.json
# 2. Prompt the agent (via its system prompt) to prefer kei_bash
# 3. Document honestly that this is advisory, not hard-enforced.
set -eu
CFG="$HOME/.gemini/config/mcp_config.json"
KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)"
if [ -z "$KEI_MCP_BIN" ] || [ ! -x "$KEI_MCP_BIN" ]; then
echo " agy: kei-mcp binary missing — build first: cargo build -p kei-mcp --release"
exit 0
fi
mkdir -p "$(dirname "$CFG")"
[ -f "$CFG" ] || echo '{}' > "$CFG"
desired=$(cat <<JSON
{
"mcpServers": {
"kei-mcp": {
"command": "$KEI_MCP_BIN"
}
}
}
JSON
)
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
echo " agy: would merge into $CFG:"
printf '%s\n' "$desired"
exit 0
fi
tmp=$(mktemp)
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
mv "$tmp" "$CFG"
cat <<EOF
agy: kei-mcp registered → $CFG
⚠ TIER 3 advisory: Antigravity has no way to disable native shell.
Native bash remains reachable and ungated. The agent reads the
system prompt (which mentions kei_bash) but may still use native.
For patent-sensitive / production-PR work, use Claude or Grok.
EOF

View file

@ -1,42 +0,0 @@
#!/usr/bin/env bash
# kei-mcp-wire-claude — verify Claude Code MCP wiring (TIER 1: already native).
#
# Claude Code reads MCP servers from ~/.claude/settings.json `mcpServers`
# block. We don't strictly need kei-mcp here (Claude's native PreToolUse
# hooks already enforce policy), but adding it gives Claude access to
# `spawn_agent` for cross-CLI sub-agent dispatch.
set -eu
CFG="$HOME/.claude/settings.json"
BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
[ -f "$BIN" ] || BIN="$(command -v kei-mcp 2>/dev/null || true)"
if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then
echo " kei-mcp binary not found — build first: cargo build -p kei-mcp --release"
exit 0
fi
echo " claude: native PreToolUse hooks already enforce policy chain (TIER 1)"
echo " kei-mcp binary: $BIN"
echo " (spawn_agent + kei_bash MCP tools available if added to"
echo " $CFG mcpServers — optional for Claude.)"
# Optional: dump merge snippet
if [ "${KEI_WIRE_CHECK:-0}" = "1" ] || [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ]; then
cat <<EOF
Suggested merge into $CFG:
{
"mcpServers": {
"kei-mcp": {
"command": "$BIN",
"env": { "CLAUDECODE": "1" }
}
}
}
(CLAUDECODE=1 tells kei-mcp to skip its hook chain — your native hooks
already fire on PreToolUse. Avoids double-enforcement.)
EOF
fi

View file

@ -1,52 +0,0 @@
#!/usr/bin/env bash
# kei-mcp-wire-copilot — TIER 2: MCP-wrapped enforcement for GitHub Copilot.
#
# Copilot CLI has NO hook system, BUT:
# 1. Supports --excluded-tools='shell' to disable native shell.
# 2. Has MCP server config at ~/.copilot/mcp-config.json.
# So: register kei-mcp via MCP, and instruct user to launch Copilot with
# --excluded-tools=shell so the agent can't use native bash and must use
# our policy-gated kei_bash.
set -eu
CFG="$HOME/.copilot/mcp-config.json"
KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)"
if [ -z "$KEI_MCP_BIN" ] || [ ! -x "$KEI_MCP_BIN" ]; then
echo " copilot: kei-mcp binary missing — build first: cargo build -p kei-mcp --release"
exit 0
fi
mkdir -p "$(dirname "$CFG")"
[ -f "$CFG" ] || echo '{}' > "$CFG"
desired=$(cat <<JSON
{
"mcpServers": {
"kei-mcp": {
"type": "stdio",
"command": "$KEI_MCP_BIN"
}
}
}
JSON
)
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
echo " copilot: would merge into $CFG:"
printf '%s\n' "$desired"
echo
echo " copilot: launch flag to enforce: --excluded-tools='shell'"
exit 0
fi
tmp=$(mktemp)
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
mv "$tmp" "$CFG"
echo " copilot: kei-mcp registered → $CFG"
echo " copilot: to enforce, launch with: copilot --excluded-tools='shell'"
echo " (this disables native shell; agent must use kei_bash via MCP)"
echo " Consider adding an alias: alias copilot='copilot --excluded-tools=shell'"

View file

@ -1,77 +0,0 @@
#!/usr/bin/env bash
# kei-mcp-wire-grok — TIER 1: port KeiSeiKit hooks to Grok's PreToolUse pipeline.
#
# Grok CLI supports Claude-Code-compatible PreToolUse hooks via
# ~/.grok/settings.json. Same JSON input contract → our existing
# ~/.claude/hooks/*.sh scripts run unchanged.
#
# We register THREE hook entries (one per Bash-gating safety hook) plus
# the kei-mcp MCP server so Grok can also call spawn_agent.
#
# Idempotent: jq-merge into existing settings.json; foreign entries survive.
set -eu
CFG="$HOME/.grok/settings.json"
HOOKS_DIR="$HOME/.claude/hooks"
KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)"
mkdir -p "$(dirname "$CFG")"
[ -f "$CFG" ] || echo '{}' > "$CFG"
# Build the hook block — three Bash hooks + two Edit/Write hooks (same as
# Claude's policy-chain.toml).
desired=$(cat <<JSON
{
"hooks": {
"PreToolUse": [
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/no-github-push.sh"}]},
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/safety-guard.sh"}]},
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/destructive-guard.sh"}]},
{"matcher": "Edit", "hooks": [{"type": "command", "command": "$HOOKS_DIR/citation-verify.sh"}]},
{"matcher": "Edit", "hooks": [{"type": "command", "command": "$HOOKS_DIR/numeric-claims-guard.sh"}]},
{"matcher": "Write", "hooks": [{"type": "command", "command": "$HOOKS_DIR/citation-verify.sh"}]},
{"matcher": "Write", "hooks": [{"type": "command", "command": "$HOOKS_DIR/numeric-claims-guard.sh"}]}
]
}
}
JSON
)
mcp_block=""
if [ -n "$KEI_MCP_BIN" ] && [ -x "$KEI_MCP_BIN" ]; then
mcp_block=$(cat <<JSON
{
"mcpServers": {
"kei-mcp": {
"command": "$KEI_MCP_BIN",
"env": { "GROKCODE": "1" }
}
}
}
JSON
)
fi
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
echo " grok: would merge into $CFG:"
printf '%s\n' "$desired"
[ -n "$mcp_block" ] && printf '%s\n' "$mcp_block"
exit 0
fi
# Merge: existing | desired (desired wins on key conflict; arrays are
# replaced, not appended — Grok PreToolUse semantics).
tmp=$(mktemp)
if [ -n "$mcp_block" ]; then
jq -s '.[0] * .[1] * .[2]' "$CFG" <(printf '%s\n' "$desired") <(printf '%s\n' "$mcp_block") > "$tmp"
else
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
fi
mv "$tmp" "$CFG"
echo " grok: wired PreToolUse hooks → $CFG"
echo " 5 hook entries (Bash×3 + Edit×2 + Write×2)"
[ -n "$mcp_block" ] && echo " kei-mcp MCP server registered (with GROKCODE=1 guard)"
echo " Same enforcement as Claude Code."

View file

@ -1,52 +0,0 @@
#!/usr/bin/env bash
# kei-mcp-wire-kimi — TIER 3: advisory enforcement for Moonshot Kimi.
#
# Kimi uses a confirmation-prompt model — no tool allowlist syntax, no
# --excluded-tools flag. The user is prompted before every native tool
# call (YOLO mode auto-approves). MCP server config at ~/.kimi/mcp.json.
# Best we can do: register kei-mcp + prompt the agent to prefer kei_bash.
set -eu
CFG="$HOME/.kimi/mcp.json"
KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)"
if [ -z "$KEI_MCP_BIN" ] || [ ! -x "$KEI_MCP_BIN" ]; then
echo " kimi: kei-mcp binary missing — build first: cargo build -p kei-mcp --release"
exit 0
fi
mkdir -p "$(dirname "$CFG")"
[ -f "$CFG" ] || echo '{"mcpServers":{}}' > "$CFG"
desired=$(cat <<JSON
{
"mcpServers": {
"kei-mcp": {
"command": "$KEI_MCP_BIN",
"transport": "stdio"
}
}
}
JSON
)
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
echo " kimi: would merge into $CFG:"
printf '%s\n' "$desired"
exit 0
fi
tmp=$(mktemp)
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
mv "$tmp" "$CFG"
cat <<EOF
kimi: kei-mcp registered → $CFG
Alternative via Kimi CLI: kimi mcp add kei-mcp --transport stdio \\
--command "$KEI_MCP_BIN"
⚠ TIER 3 advisory: Kimi has only confirmation prompts, no allowlist.
Native shell remains reachable. Keep YOLO mode OFF for safety.
For patent-sensitive work, use Claude or Grok as orchestrator.
EOF

View file

@ -1,106 +0,0 @@
#!/usr/bin/env bash
# kei-mcp-wire — orchestrator for cross-CLI MCP enforcement setup.
#
# Phase C cube — wires kei-mcp (with kei_bash/kei_edit/kei_write tools) into
# each installed LLM CLI's MCP config, plus per-CLI tool-restriction config
# where the CLI supports it.
#
# Usage:
# kei mcp-wire # detect installed CLIs + wire each
# kei mcp-wire <cli> # wire one: claude/grok/copilot/agy/kimi
# kei mcp-wire --check # diff: current vs target (no writes)
# kei mcp-wire --dry-run # preview changes without applying
# kei mcp-wire --list # show enforcement tier per CLI
#
# Enforcement tiers (3-tier honesty model):
# TIER 1 — full native: claude (existing hooks), grok (ports our hooks
# to ~/.grok/settings.json — same JSON shape)
# TIER 2 — MCP-wrapped: copilot (disable native shell + force kei_bash)
# TIER 3 — advisory: agy + kimi (cannot disable native shell;
# MCP available but enforcement is prompt-only)
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DRY_RUN=0
CHECK=0
LIST=0
TARGET=""
usage() { sed -n '2,17p' "$0" | sed 's|^# \{0,1\}||'; }
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--check) CHECK=1 ;;
--list) LIST=1 ;;
--help|-h) usage; exit 0 ;;
*) TARGET="$arg" ;;
esac
done
export KEI_WIRE_DRY_RUN="$DRY_RUN"
export KEI_WIRE_CHECK="$CHECK"
declare -A TIERS=(
[claude]="TIER 1: full native"
[grok]="TIER 1: full native (ports our hooks)"
[copilot]="TIER 2: MCP-wrapped (disable native shell)"
[agy]="TIER 3: advisory (no native-shell disable)"
[kimi]="TIER 3: advisory (confirmation model only)"
)
backend_bin() {
case "$1" in
claude) echo "claude" ;;
grok) echo "grok" ;;
agy|antigravity) echo "agy" ;;
copilot) echo "copilot" ;;
kimi) echo "kimi" ;;
*) return 1 ;;
esac
}
if [ "$LIST" = "1" ]; then
echo "Cross-CLI enforcement tiers:"
for cli in claude grok copilot agy kimi; do
bin=$(backend_bin "$cli")
if command -v "$bin" >/dev/null 2>&1; then
mark="✓"
else
mark="✗"
fi
printf " %s %-8s %s\n" "$mark" "$cli" "${TIERS[$cli]}"
done
exit 0
fi
wire_one() {
local cli="$1" wire_script="$SCRIPT_DIR/kei-mcp-wire-$cli.sh"
if [ ! -x "$wire_script" ]; then
echo "[kei-mcp-wire] no wire script for: $cli (expected $wire_script)" >&2
return 2
fi
local bin
bin=$(backend_bin "$cli") || { echo "unknown cli: $cli" >&2; return 2; }
if ! command -v "$bin" >/dev/null 2>&1; then
echo "[kei-mcp-wire] $cli not installed (skipping)"
return 0
fi
echo
echo "──── $cli (${TIERS[$cli]}) ────"
"$wire_script"
}
if [ -n "$TARGET" ]; then
wire_one "$TARGET"
exit $?
fi
# No target → wire all installed CLIs.
echo "kei-mcp-wire: detecting installed CLIs..."
for cli in claude grok copilot agy kimi; do
wire_one "$cli"
done
echo
echo "done. See \`kei mcp-wire --list\` for per-CLI enforcement tier."

View file

@ -1,71 +0,0 @@
#!/bin/sh
# kei-message — minimal persistent mailbox so ANY Claude Code session can message
# ANY other (not just Agent-Teams teammates). Append-only jsonl bus; the
# mailbox-inject.sh UserPromptSubmit hook pulls unread into each session's
# context per turn. Identity = basename of the session's cwd (or --from/--to a
# name), plus the broadcast channel "all".
#
# kei message send [--to <name|all>] [--from <name>] <text...>
# kei message inbox # messages addressed to me (cwd) or all
# kei message list # whole bus (recent)
# kei message channels # known recipient names
#
# Store: ~/.claude/mailbox/messages.jsonl (one JSON object per line)
set -eu
command -v jq >/dev/null 2>&1 || { echo "kei message: jq required" >&2; exit 1; }
MBOX="$HOME/.claude/mailbox"
LOG="$MBOX/messages.jsonl"
mkdir -p "$MBOX"
[ -f "$LOG" ] || : > "$LOG"
me="$(basename "$PWD")"
cmd="${1:-inbox}"
[ $# -gt 0 ] && shift || true
case "$cmd" in
send)
to="all"; body=""
while [ $# -gt 0 ]; do
case "$1" in
--to) to="$2"; shift; shift ;;
--from) me="$2"; shift; shift ;;
--) shift; body="$body $*"; break ;;
# Leading @name = recipient (e.g. `send @frontend hi`). First token only;
# a later @x stays literal text.
@?*) if [ "$to" = "all" ] && [ -z "$body" ]; then to="${1#@}"; else body="$body $1"; fi; shift ;;
*) body="$body $1"; shift ;;
esac
done
body="${body# }"
[ -n "$body" ] || { echo "usage: kei message send [--to <name|all>] <text>" >&2; exit 1; }
# Sub-second component: GNU `date +%N` where available; on stock macOS (BSD
# date) %N is unsupported and prints literal "N" → fall back to /dev/urandom.
# Result id = epoch(10) + 6 digits = 16-digit integer, safely < 2^53.
ns="$(date +%N 2>/dev/null)"
case "$ns" in *[!0-9]*|'') ns="$(od -An -N4 -tu4 /dev/urandom 2>/dev/null | tr -dc 0-9)" ;; esac
sub="$(printf '%s000000' "$ns" | cut -c1-6)"
id="$(date +%s)${sub}"
jq -cn --argjson id "$id" --arg ts "$(date -u +%FT%TZ)" \
--arg from "$me" --arg to "$to" --arg body "$body" \
'{id:$id, ts:$ts, from:$from, to:$to, body:$body}' >> "$LOG"
echo "-> sent to '$to' (from '$me')"
;;
inbox|read)
while [ $# -gt 0 ]; do case "$1" in --me) me="$2"; shift; shift ;; *) shift ;; esac; done
jq -r --arg me "$me" '
select(.to==$me or .to=="all")
| "[\(.ts|sub("T";" ")|sub("Z";""))] \(.from) -> \(.to): \(.body)"' "$LOG" | tail -20
;;
list|all)
jq -r '"[\(.ts|sub("T";" ")|sub("Z";""))] \(.from) -> \(.to): \(.body)"' "$LOG" | tail -40
;;
channels|names|who)
jq -r '.to, .from' "$LOG" 2>/dev/null | sort -u | grep -v '^$' || true
;;
*)
echo "kei message: send [--to <name|all>] <text> | inbox | list | channels" >&2
exit 1
;;
esac

View file

@ -1,191 +0,0 @@
#!/usr/bin/env bash
# kei-onboard — post-install wizard.
#
# Runs after install.sh / bootstrap.sh to guide the user through:
# Step 1: pick the primary LLM orchestrator (default for `kei` no-args)
# Step 2: wire kei-mcp into the chosen CLI (cross-CLI policy + spawn_agent)
# Step 3: optional MOONSHOT_API_KEY hint for kei limits
# Step 4: quick health check
#
# Idempotent — safe to re-run anytime via `kei onboard`.
# Honors TTY gate: non-interactive runs print summary + exit, no prompts.
set -eu
KEI_PRIMARY_CFG="${KEI_PRIMARY_CFG:-$HOME/.claude/config/primary.toml}"
PICK_SH="$HOME/.claude/scripts/kei-pick.sh"
WIRE_SH="$HOME/.claude/scripts/kei-mcp-wire.sh"
# Colors only if stdout is a TTY (TTY-INTERACTIVITY-GATE: -t 1 for color is OK).
C0= CB= CC= CG= CD= CR=
if [ -t 1 ]; then
C0=$'\033[0m'
CB=$'\033[1;38;5;39m' # blue
CC=$'\033[1;38;5;220m' # gold
CG=$'\033[32m' # green
CR=$'\033[31m' # red
CD=$'\033[2m' # dim
fi
# Non-interactive (no stdin TTY): print summary + exit.
# Per tty-interactivity-gate.md: -t 0 not -t 1.
if [ ! -t 0 ]; then
cat <<EOF
${CB}KeiSeiKit · onboarding${C0} (non-interactive — wizard skipped)
Next manual steps:
${CC}kei onboard${C0} run this wizard interactively
${CC}kei pick${C0} pick primary LLM CLI
${CC}kei mcp-wire${C0} wire kei-mcp into your CLIs
${CC}kei limits${C0} check subscription quotas (honest report)
${CC}kei-doctor${C0} substrate health diagnostic
EOF
exit 0
fi
# Banner
cat <<EOF
${CB}╔═══════════════════════════════════════════════════════════════════╗
║ KeiSeiKit · post-install onboarding ║
╚═══════════════════════════════════════════════════════════════════╝${C0}
The install put 38 agents, 54 hooks, and 60+ Rust primitives in place.
Now let's wire up the LLM CLIs you'll actually use.
EOF
# ── Step 1: pick primary ───────────────────────────────────────────
echo "${CB}── Step 1/4 — Pick your primary LLM orchestrator ──${C0}"
echo
echo "When you run ${CC}kei${C0} (no args) it launches your primary CLI."
echo "Each agent's manifest can also declare a preferred provider (DNA)."
echo
declare -a BACKENDS=(claude grok agy copilot kimi)
declare -A LABELS=(
[claude]="Claude Code (Anthropic, full hook enforcement)"
[grok]="Grok (xAI, native --agent flag)"
[agy]="Antigravity (Google Gemini)"
[copilot]="GitHub Copilot (Microsoft, MCP-wrapped)"
[kimi]="Kimi (Moonshot, TUI-primary)"
)
i=1
for b in "${BACKENDS[@]}"; do
if command -v "$b" >/dev/null 2>&1; then
mark="${CG}${C0}"
else
mark="${CR}${C0} ${CD}(not installed)${C0}"
fi
printf " ${CB}%d${C0}) %s %-20s %s\n" "$i" "$mark" "$b" "${LABELS[$b]}"
i=$((i+1))
done
echo " ${CB}s${C0}) skip — keep current primary (claude default)"
echo
current=""
[ -f "$KEI_PRIMARY_CFG" ] && current=$(awk -F'=' '/^provider/ {gsub(/[" ]/, "", $2); print $2; exit}' "$KEI_PRIMARY_CFG")
printf "Current primary: ${CC}%s${C0}\n" "${current:-claude (default)}"
printf "Pick [1-${#BACKENDS[@]}/s, default=s]: "
read -r choice
choice="${choice:-s}"
primary_set=""
case "$choice" in
s|S|"")
echo " ${CD}— keeping ${current:-claude}${C0}"
primary_set="${current:-claude}"
;;
[1-9])
idx=$((choice-1))
if [ $idx -ge ${#BACKENDS[@]} ] || [ $idx -lt 0 ]; then
echo " ${CR}invalid; keeping ${current:-claude}${C0}"
primary_set="${current:-claude}"
else
new="${BACKENDS[$idx]}"
mkdir -p "$(dirname "$KEI_PRIMARY_CFG")"
printf '# kei primary — written %s by onboarding\nprovider = "%s"\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$new" > "$KEI_PRIMARY_CFG"
echo " ${CG}${C0} primary set: ${CC}${new}${C0}$KEI_PRIMARY_CFG"
primary_set="$new"
fi
;;
*)
echo " ${CR}invalid; keeping ${current:-claude}${C0}"
primary_set="${current:-claude}"
;;
esac
# ── Step 2: mcp-wire ───────────────────────────────────────────────
echo
echo "${CB}── Step 2/4 — Wire kei-mcp into installed CLIs ──${C0}"
echo
echo "kei-mcp exposes ${CC}spawn_agent${C0} + ${CC}kei_bash/kei_edit/kei_write${C0} (with"
echo "policy chain) to any MCP-capable CLI. Enables cross-CLI agent invocation"
echo "AND hook enforcement on non-Claude backends."
echo
printf "Run ${CC}kei mcp-wire${C0} now (writes to ~/.grok/, ~/.copilot/, etc.)? [Y/n]: "
read -r wire_ans
wire_ans="${wire_ans:-Y}"
case "$wire_ans" in
y|Y|yes)
if [ -x "$WIRE_SH" ]; then
"$WIRE_SH"
else
echo " ${CR}$WIRE_SH not found; skip${C0}"
fi
;;
*)
echo " ${CD}— skipped. Run later: ${CC}kei mcp-wire${C0}${CD}${C0}"
;;
esac
# ── Step 3: MOONSHOT key hint ──────────────────────────────────────
echo
echo "${CB}── Step 3/4 — Live subscription limits (optional) ──${C0}"
echo
echo "${CC}kei limits${C0} probes each CLI's subscription quota. Research found that"
echo "only Kimi exposes a public API; the others are dashboard-only."
echo
if [ -n "${MOONSHOT_API_KEY:-}" ]; then
echo " ${CG}${C0} MOONSHOT_API_KEY is set — Kimi balance probing enabled"
else
cat <<EOF
${CD}Optional: set ${CC}MOONSHOT_API_KEY${CD} in ${CC}~/.claude/secrets/.env${CD} to enable
Kimi balance polling. Other CLIs: see dashboards via ${CC}kei limits${CD}.${C0}
EOF
fi
# ── Step 4: health check ───────────────────────────────────────────
echo
echo "${CB}── Step 4/4 — Health check ──${C0}"
echo
if command -v kei-doctor >/dev/null 2>&1; then
kei-doctor 2>&1 | head -20 || true
else
echo " ${CD}— kei-doctor not on PATH yet. Open new shell + run: ${CC}kei-doctor${C0}"
fi
# ── Done ───────────────────────────────────────────────────────────
cat <<EOF
${CB}╔═══════════════════════════════════════════════════════════════════╗
║ Onboarding complete. ║
╚═══════════════════════════════════════════════════════════════════╝${C0}
Quick-start:
${CC}kei${C0} launch ${primary_set} (your primary)
${CC}kei agent critic "..."${C0} invoke an agent (DNA → primary)
${CC}kei agent --on=grok critic "..."${C0} invoke on a specific backend
${CC}kei mcp-wire --list${C0} show enforcement tiers per CLI
${CC}kei limits${C0} quota report (where APIs exist)
${CC}kei pick${C0} re-pick primary anytime
${CC}kei configure${C0} re-pick hook packs / stack profile
Docs: ${CD}~/.local/share/keisei/docs/encyclopedia/${C0}
Logs: ${CD}~/.keisei-install.log${C0}
EOF

View file

@ -1,153 +0,0 @@
#!/usr/bin/env bash
# kei-pick — interactive orchestrator picker.
#
# Shows installed LLM CLIs, lets the user choose one, writes it to
# ~/.claude/config/primary.toml, then exec's it (so the shell becomes
# the picked orchestrator). Designed for `kei pick`.
#
# Non-interactive (no TTY): just shows status and exits 0.
set -eu
KEI_PRIMARY_CFG="${KEI_PRIMARY_CFG:-$HOME/.claude/config/primary.toml}"
# Mirrors scripts/kei-agent-cli.sh::backend_bin and bin/kei::backend_bin_for.
backend_bin() {
case "$1" in
claude) echo "claude" ;;
grok) echo "grok" ;;
agy|antigravity) echo "agy" ;;
copilot) echo "copilot" ;;
kimi) echo "kimi" ;;
codex) echo "codex" ;;
*) return 1 ;;
esac
}
backend_label() {
case "$1" in
claude) echo "Claude Code (Anthropic)" ;;
grok) echo "Grok Build TUI (xAI)" ;;
agy) echo "Antigravity / Gemini (Google)" ;;
copilot) echo "GitHub Copilot CLI (Microsoft/GitHub)" ;;
kimi) echo "Kimi Code CLI (Moonshot) — TUI-only for agents" ;;
codex) echo "Codex CLI (OpenAI)" ;;
esac
}
current_primary() {
[ -f "$KEI_PRIMARY_CFG" ] || { echo "claude"; return; }
awk -F'=' '/^provider[[:space:]]*=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
gsub(/^"|"$/, "", $2)
print $2; exit
}' "$KEI_PRIMARY_CFG"
}
# --- list installed backends ------------------------------------------
BACKENDS=(claude grok agy copilot kimi codex)
INSTALLED=()
for b in "${BACKENDS[@]}"; do
bin=$(backend_bin "$b")
if command -v "$bin" >/dev/null 2>&1; then
INSTALLED+=("$b")
fi
done
cur=$(current_primary)
# --- non-interactive: just show status --------------------------------
# Gate on stdin (RULE TTY-INTERACTIVITY-GATE): -t 0, not -t 1.
# curl|bash tees stdout, so -t 1 false ≠ non-interactive.
if [ ! -t 0 ]; then
echo "kei pick — non-interactive mode"
echo "current primary: $cur"
echo "installed backends: ${INSTALLED[*]:-none}"
echo "(run \`kei pick\` from a real terminal for the picker)"
exit 0
fi
# --- interactive picker -----------------------------------------------
C0="" CB="" CC="" CD=""
if [ -t 1 ]; then
C0=$'\033[0m'
CB=$'\033[1;38;5;39m' # blue
CC=$'\033[1;38;5;220m' # gold
CD=$'\033[2m' # dim
fi
cat <<EOF
${CB}╔════════════════════════════════════════════╗
║ KeiSeiKit · orchestrator picker ║
╚════════════════════════════════════════════╝${C0}
Pick the LLM CLI that becomes your primary shell.
Any agent invocation (\`kei agent <name>\`) routes here unless DNA overrides.
EOF
i=1
for b in "${BACKENDS[@]}"; do
bin=$(backend_bin "$b")
label=$(backend_label "$b")
if command -v "$bin" >/dev/null 2>&1; then
mark="${CC}${C0}"
else
mark="${CD}${C0}"
label="$label ${CD}(not installed)${C0}"
fi
cur_mark=""
[ "$b" = "$cur" ] && cur_mark="${CC} ← current${C0}"
printf " ${CB}%d${C0}) %s %-10s %s%s\n" "$i" "$mark" "$b" "$label" "$cur_mark"
i=$((i+1))
done
echo
printf " ${CB}q${C0}) cancel (keep current: ${CC}%s${C0})\n\n" "$cur"
printf "Pick [1-${#BACKENDS[@]}/q]: "
read -r choice
choice="${choice:-q}"
case "$choice" in
q|Q|"") echo "cancelled."; exit 0 ;;
[1-9])
idx=$((choice-1))
if [ $idx -ge ${#BACKENDS[@]} ] || [ $idx -lt 0 ]; then
echo "invalid choice: $choice" >&2; exit 2
fi
new="${BACKENDS[$idx]}"
;;
*) echo "invalid choice: $choice" >&2; exit 2 ;;
esac
bin=$(backend_bin "$new")
if ! command -v "$bin" >/dev/null 2>&1; then
echo
echo "${CC}'$new' is not installed.${C0}"
echo "Set as primary anyway (you'll need to install it before \`kei\` will work)? [y/N]: "
read -r confirm
case "$confirm" in y|Y|yes) ;; *) echo "cancelled."; exit 0 ;; esac
fi
mkdir -p "$(dirname "$KEI_PRIMARY_CFG")"
printf '# kei primary — written %s\nprovider = "%s"\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$new" > "$KEI_PRIMARY_CFG"
echo
echo "${CC}${C0} primary set: $cur${CC}$new${C0}"
echo " config: $KEI_PRIMARY_CFG"
echo
if [ -n "${KEI_NO_LAUNCH:-}" ]; then
echo "(skipping launch — KEI_NO_LAUNCH set)"
exit 0
fi
if ! command -v "$bin" >/dev/null 2>&1; then
echo "${CD}skipping launch — $bin not on PATH; install it then run \`kei\`.${C0}"
exit 0
fi
echo "launching $new..."
exec "$bin"

View file

@ -1,18 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# KeiSei pet state updater — called by hooks to change the pet's mood and to # KeiSei pet state updater — called by hooks to change the pet's mood.
# track running sub-agents, current language, and plan completion.
# Usage: keisei-pet-update.sh <event> # Usage: keisei-pet-update.sh <event>
# Mood events: prompt | rust_write | github_block | python_no_reason | # Events: prompt | rust_write | github_block | python_no_reason |
# modal_cost | patent_filed | concept_saved | secret_leak | # modal_cost | patent_filed | concept_saved | secret_leak |
# test_pass | test_fail | sleep | rule_violation | idle # test_pass | test_fail | sleep | rule_violation | idle
# Agent events: agent_start | agent_done (read tool JSON on stdin)
# Plan event: plan (ExitPlanMode finished)
# Language: lang (reads .tool_input.file_path)
# #
# State lives under ~/.claude/pet/: # The hook may also pipe JSON tool-context on stdin; we ignore it for now
# state — sourced shell vars (mood/message/since/day/counters/lang/plan) # (future: parse tool_input to make reactions smarter).
# agents/<id> — one file per running sub-agent: "emoji|name|start_epoch"
# agent_tokens — cumulative tokens spent by sub-agents this session
set -u set -u
@ -21,123 +15,95 @@ STATE="${STATE_DIR}/state"
HISTORY="${STATE_DIR}/history.log" HISTORY="${STATE_DIR}/history.log"
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
# Slurp stdin once (hook JSON). Non-blocking; never hang. # Load current state
INPUT="" mood="neutral"
if [ ! -t 0 ]; then INPUT="$(cat 2>/dev/null || true)"; fi message=""
since=$(date +%s)
rust_today=0
patents_today=0
violations=0
# shellcheck source=/dev/null
[ -f "$STATE" ] && source "$STATE" 2>/dev/null || true
# Daily counter reset (if state last updated yesterday)
last_day=${day:-}
today=$(date +%Y-%m-%d)
if [ "$last_day" != "$today" ]; then
rust_today=0
patents_today=0
violations=0
fi
event="${1:-}" event="${1:-}"
now=$(date +%s) now=$(date +%s)
# ── language emoji map (agent emojis live in the renderer keisei-pet.sh) ───── # Discard stdin quickly so hook doesn't block
_lang_emoji() { if [ ! -t 0 ]; then
case "$1" in cat >/dev/null 2>&1 || true
rs) echo "🦀" ;; fi
py|pyi|pyw|ipynb) echo "🐍" ;;
go) echo "🐹" ;;
ts|tsx|mts|cts) echo "📘" ;;
js|jsx|mjs|cjs) echo "🟨" ;;
swift) echo "🦅" ;;
c|h) echo "🔧" ;;
cc|cpp|cxx|hpp|hh|hxx) echo "" ;;
java) echo "☕" ;;
kt|kts) echo "🟪" ;;
rb|erb|gemspec) echo "💎" ;;
sh|bash|zsh|fish) echo "🐚" ;;
md|mdx|markdown) echo "📝" ;;
toml|ini|cfg|conf|properties) echo "🧾" ;;
json|jsonc|json5) echo "📐" ;;
yaml|yml) echo "📋" ;;
html|htm|xhtml) echo "🌐" ;;
css|scss|sass|less) echo "🎨" ;;
sql) echo "🗄️" ;;
lua) echo "🌙" ;;
php) echo "🐘" ;;
zig) echo "⚡" ;;
dart) echo "🎯" ;;
scala|sc) echo "🔺" ;;
clj|cljs|cljc|edn) echo "🍃" ;;
ex|exs|eex|heex) echo "💧" ;;
erl|hrl) echo "📡" ;;
hs|lhs) echo "🎓" ;;
ml|mli|ocaml) echo "🐫" ;;
nim) echo "👑" ;;
cr) echo "🔮" ;;
r|rmd) echo "📊" ;;
jl) echo "🔢" ;;
v|vsh) echo "🅥" ;;
vala) echo "🏛️" ;;
groovy|gradle) echo "🍀" ;;
dockerfile) echo "🐳" ;;
mk|makefile|cmake) echo "🔨" ;;
proto) echo "🔌" ;;
graphql|gql) echo "◈" ;;
vue) echo "💚" ;;
svelte) echo "🧡" ;;
astro) echo "🚀" ;;
tf|tfvars|hcl) echo "🌍" ;;
pl|pm|perl) echo "🐪" ;;
ps1|psm1) echo "🔵" ;;
nix) echo "❄️" ;;
wasm|wat) echo "🕸️" ;;
xml) echo "📰" ;;
svg) echo "🖼️" ;;
csv|tsv) echo "📊" ;;
pdf) echo "📕" ;;
lock) echo "🔒" ;;
env) echo "🔑" ;;
txt|text) echo "📄" ;;
asm|s) echo "🛠️" ;;
f|f90|f95|fortran) echo "🧮" ;;
cs) echo "🟩" ;;
fs|fsx) echo "🔷" ;;
el|lisp|scm) echo "λ" ;;
*) echo "📄" ;;
esac
}
# ── load current state ──────────────────────────────────────────────────────
mood="neutral"; message=""; since="$now"; day=""
rust_today=0; patents_today=0; violations=0; lang=""; plan=""
# shellcheck source=/dev/null
[ -f "$STATE" ] && source "$STATE" 2>/dev/null || true
# Daily counter reset
today=$(date +%Y-%m-%d)
if [ "${day:-}" != "$today" ]; then rust_today=0; patents_today=0; violations=0; fi
# ── agent / plan / language events (do not change mood face) ─────────────────
case "$event" in case "$event" in
# NOTE: running-agent tracking + token/cost accounting are NOT done here — prompt)
# the kit already does it (hooks/task-timer.sh → time-metrics/.task-*.start, mood="thinking"
# hooks/agent-event-done.sh → agent-events.jsonl). keisei-pet.sh READS those message="考えてる..."
# (SSoT). This updater only owns mood / language / plan / counters.
plan)
plan="📋"; mood="proud"; message="план готов"
;; ;;
lang) rust_write)
fp="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" rust_today=$((rust_today + 1))
if [ -n "$fp" ]; then mood="happy"
ext="${fp##*.}"; ext="$(printf '%s' "$ext" | tr '[:upper:]' '[:lower:]')" message="構造式 ✓ Rust"
lang="$(_lang_emoji "$ext")" ;;
if [ "$ext" = "rs" ]; then rust_today=$((rust_today + 1)); mood="happy"; message="構造式 ✓ Rust"; fi github_block)
fi mood="angry"
message="RULE 0.1! no github"
violations=$((violations + 1))
;;
python_no_reason)
mood="alert"
message="Python? 理由は? (RULE 0.2)"
;;
modal_cost)
mood="alert"
message="\$\$ compute check"
;;
patent_filed)
mood="proud"
patents_today=$((patents_today + 1))
message="特許 filed!"
;;
concept_saved)
mood="happy"
message="💡 concept saved"
;;
secret_leak)
mood="angry"
message="SECRET! RULE 0.8"
violations=$((violations + 1))
;;
test_pass)
mood="happy"
message="テスト ✓"
;;
test_fail)
mood="sad"
message="テスト ✗"
;;
rule_violation)
mood="angry"
message="rule violation ⚠"
violations=$((violations + 1))
;;
sleep)
mood="sleep"
message="zzz"
;;
*)
# unknown event — no-op, keep current state
:
;; ;;
prompt) mood="thinking"; message="考えてる..." ;;
rust_write) rust_today=$((rust_today + 1)); mood="happy"; message="構造式 ✓ Rust"; lang="🦀" ;;
github_block) mood="angry"; message="RULE 0.1! no github"; violations=$((violations + 1)) ;;
python_no_reason) mood="alert"; message="Python? 理由は? (RULE 0.2)" ;;
modal_cost) mood="alert"; message="\$\$ compute check" ;;
patent_filed) mood="proud"; patents_today=$((patents_today + 1)); message="特許 filed!" ;;
concept_saved) mood="happy"; message="💡 concept saved" ;;
secret_leak) mood="angry"; message="SECRET! RULE 0.8"; violations=$((violations + 1)) ;;
test_pass) mood="happy"; message="テスト ✓" ;;
test_fail) mood="sad"; message="テスト ✗" ;;
rule_violation) mood="angry"; message="rule violation ⚠"; violations=$((violations + 1)) ;;
sleep) mood="sleep"; message="zzz"; plan="" ;;
*) : ;;
esac esac
# ── write state atomically ────────────────────────────────────────────────── # Write state atomically
tmp="${STATE}.tmp.$$" tmp="${STATE}.tmp.$$"
cat > "$tmp" <<EOF cat > "$tmp" <<EOF
mood="$mood" mood="$mood"
@ -147,13 +113,14 @@ day="$today"
rust_today=$rust_today rust_today=$rust_today
patents_today=$patents_today patents_today=$patents_today
violations=$violations violations=$violations
lang="$lang"
plan="$plan"
EOF EOF
mv "$tmp" "$STATE" 2>/dev/null || true mv "$tmp" "$STATE"
printf "%s %s\n" "$(date -u +%FT%TZ)" "$event" >> "$HISTORY" 2>/dev/null || true # Rolling history (last 50 events)
if [ -f "$HISTORY" ] && [ "$(wc -l < "$HISTORY" 2>/dev/null || echo 0)" -gt 50 ]; then printf "%s %s\n" "$(date -u +%FT%TZ)" "$event" >> "$HISTORY"
tail -50 "$HISTORY" > "${HISTORY}.tmp" 2>/dev/null && mv "${HISTORY}.tmp" "$HISTORY" 2>/dev/null || true if [ -f "$HISTORY" ] && [ "$(wc -l < "$HISTORY")" -gt 50 ]; then
tail -50 "$HISTORY" > "${HISTORY}.tmp" && mv "${HISTORY}.tmp" "$HISTORY"
fi fi
# Hooks in Claude Code expect exit 0 to pass through
exit 0 exit 0

View file

@ -1,213 +1,67 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# KeiSei tamagotchi — statusline renderer. Outputs ONE line. # KeiSei tamagotchi — statusline renderer.
# SSoT: reads the kit's OWN tracking, does not maintain a parallel one. # Called by Claude Code on every prompt render. Outputs ONE line.
# - running sub-agents ← ~/.claude/memory/time-metrics/.task-<id>.start # Reads state from ~/.claude/pet/state (written by keisei-pet-update.sh).
# (written by hooks/task-timer.sh: {id,desc,type,start_epoch})
# - agent token / cost ← ~/.claude/memory/agent-events.jsonl
# (written by hooks/agent-event-done.sh: {tokens,cost_usd,...})
# - mood / lang / plan ← ~/.claude/pet/state (keisei-pet-update.sh)
set -u set -u
# Claude Code pipes the live session JSON to the statusLine on stdin. Capture
# it (don't discard) — it carries this session's token/context/cost, which is # Discard any stdin (Claude Code may pipe session JSON to statusLine)
# what replaced the default statusline when the pet took over. if [ ! -t 0 ]; then
SLINE="" cat >/dev/null 2>&1 || true
if [ ! -t 0 ]; then SLINE="$(cat 2>/dev/null || true)"; fi fi
STATE="${HOME}/.claude/pet/state" STATE="${HOME}/.claude/pet/state"
TM_DIR="${HOME}/.claude/memory/time-metrics"
EVENTS="${HOME}/.claude/memory/agent-events.jsonl"
mood="neutral"; message=""; since=$(date +%s) # Defaults (if state file missing/stale)
rust_today=0; patents_today=0; violations=0; lang=""; plan="" mood="neutral"
message=""
since=$(date +%s)
rust_today=0
patents_today=0
violations=0
# shellcheck source=/dev/null # shellcheck source=/dev/null
[ -f "$STATE" ] && source "$STATE" 2>/dev/null || true [ -f "$STATE" ] && source "$STATE" 2>/dev/null || true
now=$(date +%s) now=$(date +%s)
dim=$'\033[2m'; reset=$'\033[0m' idle=$((now - since))
_agent_emoji() { # Idle >5 min → pet sleeps (unless it's angry/alert about something)
case "$1" in if [ "$idle" -gt 300 ] && [ "$mood" != "angry" ] && [ "$mood" != "alert" ]; then
# ── project specialists (match before generic families) ── mood="sleep"
*cartoon*) echo "🎬" ;; message="zzz"
*cloudsync*) echo "🔄" ;;
*vortex*) echo "🌀" ;;
*recruiter*) echo "🧑‍💼" ;;
*leadgen*) echo "🎯" ;;
*surf*) echo "🏄" ;;
*neuralcloak*) echo "🕶️" ;;
*openclaw*) echo "🦞" ;;
*keit0*|*keisense*) echo "🖐️" ;;
*wave*) echo "🌊" ;;
*cortex*) echo "🧬" ;;
*keimd*) echo "🕸️" ;;
*keisei-os*|*keiseios*) echo "🧩" ;;
*sa-specialist*|*sa_specialist*) echo "🏝️" ;;
# ── kit agent families ──
*researcher*) echo "🔬" ;;
*architect*) echo "🏗️" ;;
*critic*) echo "🔪" ;;
*security*) echo "🛡️" ;;
*validator*) echo "✅" ;;
*cost*) echo "💰" ;;
*modal*) echo "☁️" ;;
*fal*) echo "🎨" ;;
*ml-implementer*|*ml_implementer*) echo "🧠" ;;
*ml-researcher*|*ml_researcher*) echo "📚" ;;
*infra*) echo "🔧" ;;
*implementer*) echo "⚙️" ;;
*patent*) echo "📜" ;;
*frontend*) echo "🎨" ;;
*debug*) echo "🐞" ;;
*guide*) echo "📖" ;;
Explore|*explore*) echo "🔭" ;;
Plan|*plan*) echo "📐" ;;
*general*) echo "🤖" ;;
*) echo "🤖" ;;
esac
}
_elapsed() {
local s=$1
if [ "$s" -lt 60 ]; then printf '%ds' "$s"
elif [ "$s" -lt 3600 ]; then printf '%dm' $(( s / 60 ))
else printf '%dh%dm' $(( s / 3600 )) $(( (s % 3600) / 60 )); fi
}
# ── running sub-agents (count only — compact view, no per-agent list) ────────
# Counts younger-than-2h .task-*.start markers across ALL parallel sessions.
# v0.40: dropped per-agent emoji+name list to keep status line readable when
# many parallel sessions/agents fire. Per-agent detail moved to `kei status`
# (see TODO) — pet stays as a single counter.
n_agents=0
if [ -d "$TM_DIR" ]; then
for f in "$TM_DIR"/.task-*.start; do
[ -f "$f" ] || continue
st="$(jq -r '.start_epoch // empty' "$f" 2>/dev/null)"
[ -z "$st" ] && continue
age=$(( now - st ))
[ "$age" -gt 7200 ] && continue
n_agents=$((n_agents+1))
done
fi fi
# ── today's aggregates (across ALL sessions) ───────────────────────────────── # Face + color by mood
# Tokens + cost from agent-events.jsonl; sessions from distinct parent_id of
# today's agent_spawn events.
today_tok=0; today_cost=0; today_sess=0
if [ -f "$EVENTS" ]; then
today="$(date -u +%Y-%m-%d)"
# Single awk pass: count tokens, cost, distinct parent_id.
read -r today_tok today_cost today_sess < <(awk -v d="$today" '
index($0,d)>0 {
if (match($0,/total_tokens[^0-9]*[0-9]+/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9]/,"",s); T+=s }
if (match($0,/"cost_usd"[: ]*[0-9.]+/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9.]/,"",s); C+=s }
if (match($0,/"parent_id"[: ]*"[^"]+"/)) { s=substr($0,RSTART,RLENGTH); gsub(/.*"parent_id"[: ]*"|".*/,"",s); seen[s]=1 }
} END {
n=0; for (k in seen) n++
printf "%d %.4f %d", T+0, C+0, n
}' "$EVENTS" 2>/dev/null)
fi
# Format tokens compactly: 1234567 → 1.2M / 5400 → 5k / 999 → 999
_short_tok() {
local n=${1:-0}
if [ "$n" -ge 1000000 ]; then awk -v n="$n" 'BEGIN{printf "%.1fM", n/1000000}'
elif [ "$n" -ge 1000 ]; then awk -v n="$n" 'BEGIN{printf "%dk", n/1000}'
else printf '%d' "$n"
fi
}
global=""
[ "${today_sess:-0}" -gt 0 ] 2>/dev/null && global+="💬${today_sess} "
[ "${today_tok:-0}" -gt 0 ] 2>/dev/null && global+="🌍$(_short_tok "$today_tok") "
[ "${n_agents:-0}" -gt 0 ] 2>/dev/null && global+="🤖${n_agents} "
spend=""
if [ "${today_cost:-0}" != "0.0000" ] && [ -n "${today_cost:-}" ]; then
spend="💰\$$(printf '%.2f' "$today_cost" 2>/dev/null)"
fi
[ -n "$spend" ] && global+="${spend} "
global="${global% }"
# v0.43: CLI subscription limits (best-effort).
# Pet does NOT poll — reads cache only. Cache populated by `kei limits`.
# Reality: 4 of 5 CLIs have no programmatic limit API (see research). Pet
# shows only what's actually available + how stale the cache is.
limits_cache="${HOME}/.claude/pet/limits-cache.json"
limits=""
if [ -f "$limits_cache" ]; then
# Cache age in seconds.
cache_ts=$(jq -r '.ts // empty' "$limits_cache" 2>/dev/null)
if [ -n "$cache_ts" ]; then
# Convert ISO8601 to epoch (macOS + Linux compatible).
cache_epoch=$(
date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "$cache_ts" "+%s" 2>/dev/null \
|| date -u -d "$cache_ts" "+%s" 2>/dev/null \
|| echo 0
)
cache_age=$(( now - cache_epoch ))
# Kimi balance (only CLI with live API). Show $X.XX if available.
kimi_avail=$(jq -r '.kimi | select(.status=="live") | .available_balance_usd' "$limits_cache" 2>/dev/null)
if [ -n "$kimi_avail" ] && [ "$kimi_avail" != "null" ]; then
limits+="K:\$$(printf '%.2f' "$kimi_avail" 2>/dev/null) "
fi
# Stale marker if older than 1h.
if [ "$cache_age" -gt 3600 ] 2>/dev/null && [ -n "$limits" ]; then
stale_min=$((cache_age / 60))
limits="${limits% }${dim}(${stale_min}m old)${reset} "
fi
fi
fi
limits="${limits% }"
# ── THIS session: tokens + context% (from statusLine stdin) ─────────────────
sess=""
if [ -n "$SLINE" ]; then
read -r s_in s_out s_pct < <(printf '%s' "$SLINE" | jq -r '[
(.context_window.total_input_tokens // 0),
(.context_window.total_output_tokens // 0),
(.context_window.used_percentage // 0)] | @tsv' 2>/dev/null)
st=$(( ${s_in:-0} + ${s_out:-0} ))
if [ "$st" -gt 0 ] 2>/dev/null; then
if [ "$st" -ge 1000000 ]; then tk="$(awk "BEGIN{printf \"%.1fM\",$st/1000000}")"
elif [ "$st" -ge 1000 ]; then tk="$(( st / 1000 ))k"
else tk="$st"; fi
pct="${s_pct%%.*}"; pcol=$'\033[32m'
[ "${pct:-0}" -ge 70 ] 2>/dev/null && pcol=$'\033[33m'
[ "${pct:-0}" -ge 90 ] 2>/dev/null && pcol=$'\033[31m'
sess="🪙${tk} ${pcol}${pct}%${reset}"
fi
fi
# ── mood face ───────────────────────────────────────────────────────────────
idle=$(( now - since ))
if [ "$idle" -gt 300 ] && [ "$mood" != "angry" ] && [ "$mood" != "alert" ] && [ "$n_agents" -eq 0 ]; then
mood="sleep"; message="zzz"
fi
case "$mood" in case "$mood" in
happy) face="(ᵔᴥᵔ)"; color=$'\033[32m';; proud) face="(•̀ᴗ•́)و"; color=$'\033[1;32m';; happy) face="(ᵔᴥᵔ)"; color=$'\033[32m' ;; # green
thinking) face="(⊙.⊙)"; color=$'\033[36m';; alert) face="(ʘᴗʘ)"; color=$'\033[33m';; proud) face="(•̀ᴗ•́)و"; color=$'\033[1;32m';; # bright green
angry) face="(ò_ó)"; color=$'\033[31m';; sad) face="(╥﹏╥)"; color=$'\033[34m';; thinking) face="(⊙.⊙)"; color=$'\033[36m' ;; # cyan
sleep) face="(-.-)"; color=$'\033[2;37m';; *) face="(•ᴗ•)"; color=$'\033[37m';; alert) face="(ʘᴗʘ)"; color=$'\033[33m' ;; # yellow
angry) face="(ò_ó)"; color=$'\033[31m' ;; # red
sad) face="(╥﹏╥)"; color=$'\033[34m' ;; # blue
sleep) face="(-.-)"; color=$'\033[2;37m';; # dim gray
*) face="(•ᴗ•)"; color=$'\033[37m' ;; # white (neutral)
esac esac
stats="" dim=$'\033[2m'
[ "${rust_today:-0}" -gt 0 ] 2>/dev/null && stats+=" 🦀${rust_today}" reset=$'\033[0m'
[ "${patents_today:-0}" -gt 0 ] 2>/dev/null && stats+=" 📜${patents_today}"
# recent errors — from the kit's error-spike-detector rolling window (SSoT)
errn=0
EWIN="${HOME}/.claude/memory/error-window.txt"
[ -f "$EWIN" ] && errn="$(awk '$2==1' "$EWIN" 2>/dev/null | wc -l | tr -d ' ')"
[ "${errn:-0}" -gt 0 ] 2>/dev/null && stats+=" $(printf '\033[31m')${errn}${reset}"
[ "${violations:-0}" -gt 0 ] 2>/dev/null && stats+="${violations}"
proj="${PWD##*/}"; [ -z "$proj" ] && proj="~"
out="" # stats line (compact)
[ -n "$sess" ] && out+="${sess} " stats=""
[ -n "$global" ] && out+="${dim}${global}${reset} " [ "$rust_today" -gt 0 ] && stats+=" 🦀${rust_today}"
[ -n "$limits" ] && out+="${dim}${limits}${reset} " [ "$patents_today" -gt 0 ] && stats+=" 📜${patents_today}"
[ -n "$plan" ] && out+="${plan} " [ "$violations" -gt 0 ] && stats+="${violations}"
out+="${color}${face}${reset}"
[ -n "$message" ] && out+=" ${dim}${message}${reset}" # Project name from PWD
out+="${stats}" proj="${PWD##*/}"
[ -n "$lang" ] && out+=" ${lang}" [ -z "$proj" ] && proj="~"
out+=" ${dim}📁 ${proj}${reset}"
printf '%s' "$out" # Render: face | message | stats | project
# Keep it ≤ one line
printf "%s%s%s %s%s%s%s%s %s%s%s" \
"$color" "$face" "$reset" \
"$dim" "$message" "$reset" \
"$stats" \
"" \
"$dim" "📁 $proj" "$reset"

View file

@ -26,7 +26,7 @@
}, },
{ {
"type": "command", "type": "command",
"command": "~/.claude/scripts/keisei-pet-update.sh lang" "command": "FILE=$(cat | jq -r '.tool_input.file_path // empty'); [ -n \"$FILE\" ] && [ \"${FILE##*.}\" = 'rs' ] && ~/.claude/scripts/keisei-pet-update.sh rust_write; exit 0"
} }
] ]
}, },
@ -62,19 +62,6 @@
"type": "command", "type": "command",
"command": "~/.claude/hooks/agent-stub-scan.sh", "command": "~/.claude/hooks/agent-stub-scan.sh",
"statusMessage": "STATUS-TRUTH marker scan (RULE 0.16)..." "statusMessage": "STATUS-TRUTH marker scan (RULE 0.16)..."
},
{
"type": "command",
"command": "~/.claude/hooks/agent-event-done.sh"
}
]
},
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "~/.claude/scripts/keisei-pet-update.sh plan"
} }
] ]
}, },
@ -202,10 +189,6 @@
{ {
"type": "command", "type": "command",
"command": "~/.claude/hooks/task-timer.sh" "command": "~/.claude/hooks/task-timer.sh"
},
{
"type": "command",
"command": "~/.claude/hooks/agent-event-spawn.sh"
} }
] ]
} }
@ -241,11 +224,6 @@
{ {
"type": "command", "type": "command",
"command": "~/.claude/scripts/keisei-pet-update.sh prompt" "command": "~/.claude/scripts/keisei-pet-update.sh prompt"
},
{
"type": "command",
"command": "~/.claude/hooks/mailbox-inject.sh",
"statusMessage": "kei mailbox pull-inbox..."
} }
] ]
} }
@ -284,18 +262,6 @@
} }
] ]
} }
],
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/first-run-onboard.sh",
"statusMessage": "KeiSeiKit first-run onboard nudge..."
}
]
}
] ]
} }
} }

View file

@ -27,7 +27,7 @@ Store the reply verbatim as `REPO`.
} }
``` ```
Store as `PLATFORM`. If `Both` is selected, emit a one-line confirm: "You understand — only public-safe code ever pushes to GitHub?" and wait for a `y` typed reply before proceeding. Store as `PLATFORM`. If `Both` is selected, emit a one-line confirm: "You understand — only non-patent code ever pushes to GitHub?" and wait for a `y` typed reply before proceeding.
## 1c — Languages click (AskUserQuestion, multi-select) ## 1c — Languages click (AskUserQuestion, multi-select)

View file

@ -1,62 +0,0 @@
---
name: msg
description: Read or write the cross-session mailbox by @id. Send a message to another Claude Code session (`/msg @name text`), read your own inbox (`/msg` with no args), broadcast to everyone (`/msg all text`), list the whole bus, or discover who is reachable. Thin wrapper over the `kei message` jsonl mailbox — messages land in the recipient's NEXT turn via the mailbox-inject hook (pull, not push). Use whenever the user wants sessions/agents to talk to each other.
argument-hint: "[@name] <message> | (empty = read inbox) | list | who"
---
# /msg — Inter-Session Mailbox
A persistent append-only bus so ANY Claude Code session can message ANY other —
not just Agent-Teams teammates, no tmux, no daemon. Backed by
`~/.claude/scripts/kei-message.sh` writing `~/.claude/mailbox/messages.jsonl`.
The `mailbox-inject.sh` UserPromptSubmit hook pulls each session's unread into
its context once per turn, so delivery is **pull** (arrives on the recipient's
next turn), not instant push.
## Identity model
- **Your address** = the basename of this session's working directory (`$PWD`).
So a session running in `~/Projects/frontend` is reachable as `@frontend`.
- **`all`** is the broadcast channel — every session sees `to:"all"` messages.
- You can override the sender with `--from <name>` and the reader identity with
`--me <name>` if a session's cwd basename isn't the name you want to use.
## Command map
Interpret `$ARGUMENTS` and run the matching command via Bash, then show its
output to the user. The launcher `kei message …` and the script path are
equivalent — prefer the script path (always present after install):
| User typed | Run |
|---|---|
| `/msg` (no args) | `~/.claude/scripts/kei-message.sh inbox` |
| `/msg @frontend ship it` | `~/.claude/scripts/kei-message.sh send @frontend ship it` |
| `/msg all standup in 5` | `~/.claude/scripts/kei-message.sh send all standup in 5` |
| `/msg list` | `~/.claude/scripts/kei-message.sh list` |
| `/msg who` (or `channels`) | `~/.claude/scripts/kei-message.sh channels` |
Rules for parsing `$ARGUMENTS`:
1. **Empty** → read inbox (`inbox`). Show the messages addressed to this
session or to `all`.
2. **Starts with `@<name>`** → send to that recipient; the rest is the body.
A `@x` that appears later in the body stays literal text.
3. **Starts with `all `** → broadcast; the rest is the body.
4. **`list`** → print the recent whole bus (every from→to line).
5. **`who` / `channels`** → print known recipient names (use this to discover
who is reachable before sending the first message).
6. Anything else with no leading `@`/`all` → treat as a broadcast body, OR ask
the user who the recipient is if it's ambiguous.
## Discovery (first-message problem)
A recipient only appears in `who` after it has sent or been sent a message, so
for the very first contact either broadcast with `all`, or ask the user for the
target session's cwd-basename. Don't invent a recipient name.
## Notes
- Sending never blocks and never notifies the recipient out-of-band — they see
it on their next turn. For a time-sensitive ping, tell the user it's queued.
- This is plain files: `cat ~/.claude/mailbox/messages.jsonl` is the raw bus.
- Bypass the inject hook for a session with `KEI_MAILBOX_BYPASS=1`.

View file

@ -14,7 +14,7 @@
# #
# Env / args: # Env / args:
# KEISEI_ROOT install dir (default: $HOME/.local/share/keisei) # KEISEI_ROOT install dir (default: $HOME/.local/share/keisei)
# KEISEI_REPO git URL (default: https://github.com/KeiSeiLab/KeiSeiKit-1.0.git) # KEISEI_REPO git URL (default: https://keigit.com/keisei/KeiSeiKit-1.0.git)
# KEISEI_REF branch/tag/sha (default: main) # KEISEI_REF branch/tag/sha (default: main)
# --profile=NAME passed through to ./bootstrap.sh # --profile=NAME passed through to ./bootstrap.sh
# --yes passed through to ./bootstrap.sh # --yes passed through to ./bootstrap.sh
@ -24,7 +24,7 @@
set -euo pipefail set -euo pipefail
KEISEI_ROOT="${KEISEI_ROOT:-$HOME/.local/share/keisei}" KEISEI_ROOT="${KEISEI_ROOT:-$HOME/.local/share/keisei}"
KEISEI_REPO="${KEISEI_REPO:-https://github.com/KeiSeiLab/KeiSeiKit-1.0.git}" KEISEI_REPO="${KEISEI_REPO:-https://keigit.com/keisei/KeiSeiKit-1.0.git}"
KEISEI_REF="${KEISEI_REF:-main}" KEISEI_REF="${KEISEI_REF:-main}"
PASS_THROUGH=() PASS_THROUGH=()
@ -78,13 +78,6 @@ mkdir -p "$(dirname "$KEISEI_ROOT")"
if [ -d "$KEISEI_ROOT/.git" ]; then if [ -d "$KEISEI_ROOT/.git" ]; then
say "pulling $KEISEI_REF in $KEISEI_ROOT" say "pulling $KEISEI_REF in $KEISEI_ROOT"
git -C "$KEISEI_ROOT" fetch --depth=1 origin "$KEISEI_REF" git -C "$KEISEI_ROOT" fetch --depth=1 origin "$KEISEI_REF"
# reset --hard discards ANY local edits in this managed clone. It's a managed
# dir (don't hand-edit it — change the repo + push instead), but warn loudly
# so a manual tweak isn't lost silently.
if ! git -C "$KEISEI_ROOT" diff --quiet 2>/dev/null \
|| ! git -C "$KEISEI_ROOT" diff --cached --quiet 2>/dev/null; then
say " ⚠ local changes in $KEISEI_ROOT will be DISCARDED by reset --hard"
fi
git -C "$KEISEI_ROOT" reset --hard "origin/$KEISEI_REF" git -C "$KEISEI_ROOT" reset --hard "origin/$KEISEI_REF"
else else
say "cloning $KEISEI_REPO ($KEISEI_REF) → $KEISEI_ROOT" say "cloning $KEISEI_REPO ($KEISEI_REF) → $KEISEI_ROOT"
@ -98,20 +91,11 @@ say "delegating to $KEISEI_ROOT/bootstrap.sh ${PASS_THROUGH[*]:-}"
cd "$KEISEI_ROOT" cd "$KEISEI_ROOT"
# curl|bash сценарий: stdin = pipe от curl, поэтому wizard'у read нечего читать. # curl|bash сценарий: stdin = pipe от curl, поэтому wizard'у read нечего читать.
# Если /dev/tty реально ОТКРЫВАЕТСЯ (сессия интерактивная), запускаем bootstrap # Если есть /dev/tty (т.е. сессия реально интерактивная), переподключаем stdin
# со stdin = терминал — иначе onboarding/whiptail падают на первом prompt. # к терминалу — иначе onboarding/whiptail падают на первом prompt.
# ВАЖНО #1: `[ -r /dev/tty ]` недостаточно — путь может stat'иться readable, но # audit 2026-05-18 bug #4.
# `exec < /dev/tty` падает с "No such device or address" когда нет управляющего if [ -r /dev/tty ] && [ ! -t 0 ]; then
# терминала (ssh non-interactive, CI, cron). Поэтому пробуем реально открыть его exec < /dev/tty
# через `{ : < /dev/tty; }` и реаттачим ТОЛЬКО при успехе.
# ВАЖНО #2: bash читает ЭТОТ скрипт из трубы curl ПОБАЙТНО. Отдельный
# `exec < /dev/tty` заставил бы bash читать СЛЕДУЮЩУЮ строку (сам запуск
# bootstrap) уже с клавиатуры → вечный висяк после "delegating". Поэтому редирект
# и exec ОБЯЗАНЫ быть ОДНОЙ командой: подменили stdin и сразу заменили процесс —
# bash больше не читает ни байта скрипта из (уже не той) трубы.
# audit 2026-05-18 bug #4; non-TTY e2e fix 2026-05-21; curl|bash hang fix 2026-05-22.
if [ ! -t 0 ] && { : < /dev/tty; } 2>/dev/null; then
exec ./bootstrap.sh "${PASS_THROUGH[@]}" < /dev/tty
fi fi
exec ./bootstrap.sh "${PASS_THROUGH[@]}" exec ./bootstrap.sh "${PASS_THROUGH[@]}"