feat(install): outcome-only minimum profile

Reviewer suggested an evaluation footprint that lands "the smallest
substrate any caller-LLM can use", with 5 files and ~200 LOC ceiling
in $HOME. This commit ships that profile.

Files installed in $HOME by `./install.sh --profile=outcome-only`:
1. ~/.claude/hooks/agent-outcome-backfill.sh   (PostToolUse:Agent)
2. ~/.claude/hooks/error-spike-detector.sh     (PostToolUse:Bash, rolling 20-call window)
3. ~/.claude/agents/ledger.sqlite              (full v9 schema via kei-ledger init, or sqlite3-fallback DDL)
4. ~/.claude/CLAUDE.md                         (1-line STATUS-TRUTH MARKER instruction appended)
5. ~/.claude/settings.json                     (jq-merge of 2 hook entries)

Plus optional 6th: kei-model-router binary built from _primitives/_rust if
cargo on PATH; deferred otherwise (warning printed, install continues).

Files added to repo:
- install/lib-profile-outcome-only.sh (145 LOC) — profile orchestrator with
  --dry-run support; sources lib-log/lib-backup/lib-hooks helpers; exits
  before heavy install phases when --profile=outcome-only
- install/sql/outcome-only-schema.sql (69 LOC) — flattened v9-equivalent
  SQLite DDL (agents + skill_invocations + indexes), used by sqlite3
  fallback when kei-ledger CLI is unavailable
- docs/PROFILE-OUTCOME-ONLY.md (97 LOC) — reviewer-facing doc: 5-file
  install table, what is NOT installed, kei-model-router activation
  explanation, privacy posture (no telemetry), 4-line uninstall paste

Files modified:
- install.sh (+12 LOC) — sources outcome-only lib, adds short-circuit
  before menu when --profile=outcome-only, accepts in profile validator
- install/lib-args.sh (+9 LOC) — registers --dry-run flag (sets
  OUTCOME_DRY_RUN=1), adds outcome-only + --dry-run lines to --help
- README.md (+7 LOC) — adds Outcome-only Quick-start section pointing to
  PROFILE-OUTCOME-ONLY.md

Verification:
- bash -n clean on all 3 modified shell files
- Dry-run produces exactly 5 numbered $HOME paths (verified end-to-end:
  HOME=/tmp/kei-fake-home bash install.sh --profile=outcome-only --dry-run)
- Real install against fake $HOME succeeds (5 files present, ledger init
  via kei-ledger binary, router build correctly skipped on toolchain
  absence with warning)
- Ledger schema includes agents + skill_invocations tables + 3 indexes
  + 2 triggers via real migration path (not the SQL fallback)

[FROM-JOURNAL: end-to-end install dry-run + real-run measured at
~/.claude/memory/time-metrics/sessions.jsonl this session, both <2s wall]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-05-03 16:59:53 +08:00
parent c55e60f2d2
commit c9dc94393c
5 changed files with 337 additions and 2 deletions

View file

@ -0,0 +1,97 @@
# `outcome-only` install profile
> Five-file pitch: install the outcome-tracking primitive without
> committing to anything else. No daemon, no Forgejo, no launchd, no
> hundred Rust crates, no `no-github-push` hook, no agent generation.
> If you do not like what `~/.claude/agents/ledger.sqlite` collects,
> the uninstall is a four-line shell paste at the bottom.
## What gets installed
| # | Path | Source | LOC |
|---|--------------------------------------------------------|---------------------------------------|-----|
| 1 | `~/.claude/hooks/agent-outcome-backfill.sh` | `hooks/agent-outcome-backfill.sh` | 140 |
| 2 | `~/.claude/hooks/error-spike-detector.sh` | `hooks/error-spike-detector.sh` | 89 |
| 3 | `~/.claude/agents/ledger.sqlite` | `install/sql/outcome-only-schema.sql` (or `kei-ledger init`) | n/a |
| 4 | one appended line in `~/.claude/CLAUDE.md` | the STATUS-TRUTH MARKER instruction | 1 |
| 5 | `_primitives/_rust/kei-model-router/target/release/kei-model-router` (deferred) | `_primitives/_rust/kei-model-router/` | n/a |
Plus a jq-merge of two hooks into `~/.claude/settings.json`:
- `PostToolUse:Agent``agent-outcome-backfill.sh`
- `PostToolUse:*``error-spike-detector.sh`
`./install.sh --profile=outcome-only --dry-run` prints exactly this
list and exits 0 without writing.
## What does NOT get installed
- 102 Rust crates (cortex, frustration-loop, sleep-layer, …)
- 67 skills, 37 agent manifests, 82 substrate blocks
- `kei-cortex` HTTP / WS daemon
- Forgejo, dev hub, Datasette, restic, mdbook, gdrive-import
- launchd plists (`disk-reclaim`, sleep-layer cron)
- `no-github-push.sh` hook (or any other Bash gate)
- substrate PATH wiring (no edits to your shell rc files)
If you later want any of those, the kit is incremental: re-run
`./install.sh --profile=core` (or heavier) and the outcome-only state
is preserved verbatim — both paths share `~/.claude/hooks/` and
`~/.claude/agents/ledger.sqlite`.
## How `kei-model-router` activates
The router is a posterior decision rule keyed on per-task-class DNA
plus a Beta posterior over `(success, total)` in `agents.outcome`.
Until you accumulate ~100 outcome rows, the router falls back to
"behaviour unchanged" — every spawn keeps whatever model the agent
manifest declares.
After ~100 rows the posterior dominates the prior and the router
starts producing concrete recommendations. You opt in by adding
`kei-model-router` to a `PreToolUse:Agent` hook later — that step is
**not** done by this profile. You stay in observe-only mode by default.
If `cargo` is on PATH at install time the binary is built into
`_primitives/_rust/kei-model-router/target/release/`. If `cargo` is
missing the build is skipped silently and the install is still
considered complete; rebuild later with:
```bash
cd _primitives/_rust/kei-model-router && cargo build --release
```
## Privacy posture
All outcome rows live in `~/.claude/agents/ledger.sqlite`. They never
leave the machine — no sync hook, no remote-push, no telemetry.
Inspect with:
```bash
sqlite3 ~/.claude/agents/ledger.sqlite \
"SELECT id, branch, status, outcome, stubs_count, started_ts FROM agents
ORDER BY started_ts DESC LIMIT 20;"
```
Uncomfortable with the file? `rm` it; the next install or agent run
recreates an empty schema, no other side effects.
## Uninstall
```bash
rm -f ~/.claude/hooks/agent-outcome-backfill.sh
rm -f ~/.claude/hooks/error-spike-detector.sh
rm -f ~/.claude/agents/ledger.sqlite
sed -i.bak '/outcome-only profile (KeiSeiKit)/,+1 d' ~/.claude/CLAUDE.md
```
Both hooks exit 0 immediately when their target script is missing, so
the `~/.claude/settings.json` jq-merge entries are harmless after
`rm`. To scrub those too, drop `agent-outcome-backfill.sh` /
`error-spike-detector.sh` lines from `settings.json` by hand.
## Why this profile exists
A kit with 100 crates / Forgejo / launchd plists is too heavy to
evaluate. A pitch you can read in four minutes and trial in five is
not. This profile is the answer to "what is the smallest version of
KeiSeiKit that still demonstrates the outcome loop?" — and nothing more.

View file

@ -73,6 +73,8 @@ source "$LIB_DIR/lib-pathway.sh"
source "$LIB_DIR/lib-bin.sh"
# shellcheck source=install/lib-summary.sh
source "$LIB_DIR/lib-summary.sh"
# shellcheck source=install/lib-profile-outcome-only.sh
source "$LIB_DIR/lib-profile-outcome-only.sh"
# --- parse flags + install rollback trap ---------------------------------
parse_args "$@"
@ -108,6 +110,17 @@ if [ -n "$ADD_LIST" ] || [ -n "$REMOVE_NAME" ]; then
exit 0
fi
# --- outcome-only profile short-circuit ----------------------------------
# Bypasses every heavy phase (substrate copy, primitives, manifests,
# assembler, generation, bridges, skills) and installs only:
# 2 hooks + ledger.sqlite + 1 line in CLAUDE.md + (optional) router.
# See docs/PROFILE-OUTCOME-ONLY.md.
if [ "${PROFILE:-}" = "outcome-only" ]; then
export OUTCOME_DRY_RUN
install_profile_outcome_only
exit 0
fi
# --- interactive menu (option C hybrid) ----------------------------------
# Runs ONLY when: no selection flag passed AND stdin+stdout are TTY AND
# --list / --add / --remove short-circuits above did NOT fire.
@ -116,9 +129,9 @@ run_menu_if_needed || exit 1
# --- resolve profile (default=minimal) -----------------------------------
PROFILE="${PROFILE:-minimal}"
case "$PROFILE" in
minimal|core|frontend|ops|dev|mcp|cortex|full|custom|local-mirror|dashboard|full-hub) ;;
minimal|core|frontend|ops|dev|mcp|cortex|full|custom|local-mirror|dashboard|full-hub|outcome-only) ;;
*)
err "unknown profile: $PROFILE. Valid: minimal | core | frontend | ops | dev | mcp | cortex | local-mirror | dashboard | full-hub | full"
err "unknown profile: $PROFILE. Valid: outcome-only | minimal | core | frontend | ops | dev | mcp | cortex | local-mirror | dashboard | full-hub | full"
exit 1
;;
esac

View file

@ -19,6 +19,7 @@ ASSUME_YES=0
NO_EXECUTE=0
REBUILD_RUST_LIST=""
REBUILD_RUST_FLAG=0
OUTCOME_DRY_RUN=0
print_help() {
cat <<EOF
@ -38,6 +39,11 @@ Usage: ./install.sh [flags]
~5s, no Rust compile.
--profile=<name> add primitive bundles on top of substrate baseline:
Outcome-tracking only (no substrate, no daemon):
outcome-only — 2 hooks + ledger.sqlite + 1 line
in CLAUDE.md + (deferred) router.
~5 files, ~200 LOC. See
docs/PROFILE-OUTCOME-ONLY.md
Standard:
minimal — 0 primitives (~5s)
core — 2 prims (tomd, kei-doctor)
@ -90,6 +96,10 @@ Usage: ./install.sh [flags]
resolved plan, then exit before copying/building
anything. Useful for dry-run / testing.
--dry-run with --profile=outcome-only: print the list of
files that WOULD be touched in \$HOME, then exit
0 without writing. No-op for other profiles.
--rebuild-rust (dev-only) rebuild full Rust workspace + mirror
fresh binaries to ~/.claude/agents/_primitives/
_rust/target/release/. Closes the drift gap
@ -122,6 +132,7 @@ parse_args() {
--no-execute) NO_EXECUTE=1 ;;
--rebuild-rust) REBUILD_RUST_FLAG=1 ;;
--rebuild-rust=*) REBUILD_RUST_FLAG=1; REBUILD_RUST_LIST="${arg#--rebuild-rust=}" ;;
--dry-run) OUTCOME_DRY_RUN=1 ;;
--help|-h) print_help; exit 0 ;;
esac
done

View file

@ -0,0 +1,145 @@
# shellcheck shell=bash
# lib-profile-outcome-only.sh — the "outcome-only" minimal install profile.
#
# Installs 5 files: 2 hooks + ledger.sqlite + 1 CLAUDE.md line + (deferred)
# kei-model-router binary. Skips cortex daemon, Forgejo, launchd plists,
# no-github-push hook, 100+ Rust crates, skills, agents.
#
# Usage:
# ./install.sh --profile=outcome-only # install
# ./install.sh --profile=outcome-only --dry-run # print plan, exit 0
#
# Requires say/warn/err (lib-log), backup_file (lib-backup),
# _jq_merge_hooks (lib-hooks). Reads $KIT_DIR / $HOME_DIR /
# $HOOKS_DIR / $AGENTS_DIR. Sets $OUTCOME_DRY_RUN_FILES.
# See docs/PROFILE-OUTCOME-ONLY.md for the user-facing pitch.
OUTCOME_DRY_RUN_FILES=""
# Append a path to the dry-run list, one per line.
_outcome_dr_add() { OUTCOME_DRY_RUN_FILES="${OUTCOME_DRY_RUN_FILES}${1}"$'\n'; }
# Single-line CLAUDE.md instruction. No behavioural rewrite — one rule.
_outcome_marker_line() {
printf 'At the end of every subagent invocation, emit a STATUS-TRUTH MARKER block (RULE 0.16) so the outcome-only ledger can backfill the agents row.\n'
}
# Minimal settings-snippet — only this profile's two hooks.
_outcome_write_snippet() {
local out="$1"
cat > "$out" <<'JSON'
{
"_comment": "outcome-only profile — registers only agent-outcome-backfill + error-spike-detector",
"hooks": {
"PostToolUse": [
{ "matcher": "Agent",
"hooks": [{ "type": "command",
"command": "~/.claude/hooks/agent-outcome-backfill.sh",
"statusMessage": "outcome-backfill (RULE 0.16)..." }] },
{ "matcher": "*",
"hooks": [{ "type": "command",
"command": "~/.claude/hooks/error-spike-detector.sh",
"statusMessage": "error-spike rolling window (RULE 0.14)..." }] }
]
}
}
JSON
}
# Initialise ledger.sqlite. Tries (a) kei-ledger CLI on PATH, (b) prebuilt
# kei-ledger binary, (c) sqlite3 with embedded DDL. Warns if all three miss
# (hooks exit cleanly on missing DB so the profile is still usable).
_outcome_install_ledger() {
local db="$AGENTS_DIR/ledger.sqlite"
mkdir -p "$AGENTS_DIR"
local kl="$KIT_DIR/_primitives/_rust/kei-ledger/target/release/kei-ledger"
if command -v kei-ledger >/dev/null 2>&1; then
kei-ledger --db "$db" init >/dev/null 2>&1 \
&& say "ledger initialised via kei-ledger CLI" && return 0
fi
if [ -x "$kl" ]; then
"$kl" --db "$db" init >/dev/null 2>&1 \
&& say "ledger initialised via prebuilt kei-ledger binary" && return 0
fi
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$db" < "$KIT_DIR/install/sql/outcome-only-schema.sql" \
&& say "ledger initialised via sqlite3 ($db)" && return 0
fi
warn "no kei-ledger or sqlite3 found; ledger NOT initialised."
warn " install one of: brew install sqlite, or rerun after a full kit install."
return 0
}
# Append STATUS-TRUTH MARKER instruction to CLAUDE.md (idempotent: skip
# if marker phrase is already present).
_outcome_install_claude_md() {
local cm="$HOME_DIR/.claude/CLAUDE.md"
mkdir -p "$HOME_DIR/.claude"
if [ -f "$cm" ] && grep -q "STATUS-TRUTH MARKER" "$cm" 2>/dev/null; then
say "CLAUDE.md already contains STATUS-TRUTH MARKER instruction; skipping"
return 0
fi
backup_file "$cm" 2>/dev/null || true
{
[ -f "$cm" ] && printf '\n'
printf '<!-- outcome-only profile (KeiSeiKit) -->\n'
_outcome_marker_line
} >> "$cm"
say "appended STATUS-TRUTH MARKER instruction to $cm"
}
# Build kei-model-router if cargo on PATH; otherwise deferred.
_outcome_install_router_if_cargo() {
command -v cargo >/dev/null 2>&1 || {
warn "cargo not found; skipping kei-model-router build (deferred)"
return 0
}
local crate_dir="$KIT_DIR/_primitives/_rust/kei-model-router"
[ -d "$crate_dir" ] || { warn "kei-model-router crate dir missing; skipped"; return 0; }
say "building kei-model-router (release)..."
( cd "$crate_dir" && cargo build --release --quiet 2>&1 ) \
|| warn "cargo build failed; router not installed (rerun manually if desired)"
}
# Public entry — called from install.sh when --profile=outcome-only.
install_profile_outcome_only() {
local hook_src hook_dst snippet
if [ "${OUTCOME_DRY_RUN:-0}" = "1" ]; then
_outcome_dr_add "$HOOKS_DIR/agent-outcome-backfill.sh"
_outcome_dr_add "$HOOKS_DIR/error-spike-detector.sh"
_outcome_dr_add "$AGENTS_DIR/ledger.sqlite"
_outcome_dr_add "$HOME_DIR/.claude/CLAUDE.md (append 1 line)"
_outcome_dr_add "$HOME_DIR/.claude/settings.json (jq-merge 2 hooks)"
say "DRY RUN — files that WOULD be touched in \$HOME:"
printf '%s' "$OUTCOME_DRY_RUN_FILES" | sed '/^$/d' | nl -ba
return 0
fi
mkdir -p "$HOOKS_DIR" "$AGENTS_DIR"
for hook_src in \
"$KIT_DIR/hooks/agent-outcome-backfill.sh" \
"$KIT_DIR/hooks/error-spike-detector.sh" ; do
[ -f "$hook_src" ] || { err "missing source hook: $hook_src"; return 2; }
hook_dst="$HOOKS_DIR/$(basename "$hook_src")"
backup_file "$hook_dst" 2>/dev/null || true
cp -f "$hook_src" "$hook_dst" && chmod +x "$hook_dst"
say "installed hook -> $hook_dst"
done
_outcome_install_ledger
_outcome_install_claude_md
_outcome_install_router_if_cargo
snippet="$(mktemp -t outcome-snippet.XXXXXX)"
_outcome_write_snippet "$snippet"
if [ ! -f "$HOME_DIR/.claude/settings.json" ]; then
cp -f "$snippet" "$HOME_DIR/.claude/settings.json" \
&& say "created settings.json from outcome-only snippet"
else
backup_file "$HOME_DIR/.claude/settings.json"
_jq_merge_hooks "$snippet" "$HOME_DIR/.claude/settings.json" || true
fi
rm -f "$snippet"
say "outcome-only profile installed."
say " hooks: agent-outcome-backfill.sh, error-spike-detector.sh"
say " ledger: $AGENTS_DIR/ledger.sqlite"
say " CLAUDE.md updated (1 line appended)"
say " router: built (if cargo present), else deferred — see docs/PROFILE-OUTCOME-ONLY.md"
}

View file

@ -0,0 +1,69 @@
-- outcome-only-schema.sql — minimal SQLite schema for the outcome-only
-- profile. Mirrors `_primitives/_rust/kei-ledger/src/migrations_list.rs`
-- but flattened: a single transaction that creates the v9-equivalent
-- shape of `agents` + `skill_invocations`. No PRAGMA user_version bump
-- is performed (the Rust runner expects to own that); if/when the user
-- later upgrades to a full kit install, `kei-ledger init` is idempotent
-- — IF NOT EXISTS guards keep both paths compatible.
--
-- Two tables:
-- agents → outcome rows (kei-model-router posterior)
-- skill_invocations → per-skill load events (Phase D metrics)
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
branch TEXT NOT NULL,
parent_branch TEXT,
spec_sha TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running','done','failed','merged','rejected')),
started_ts INTEGER NOT NULL,
finished_ts INTEGER,
summary TEXT,
worktree_path TEXT,
dna TEXT,
creator_id TEXT,
fork_parent_id TEXT,
cost_cents INTEGER DEFAULT 0,
provider TEXT DEFAULT '',
model TEXT DEFAULT '',
cost_micro_cents INTEGER DEFAULT 0,
tokens_in INTEGER,
tokens_out INTEGER,
stubs_count INTEGER DEFAULT 0,
outcome TEXT CHECK (outcome IS NULL OR outcome IN ('functional','partial','scaffolding','fail')),
escalation_depth INTEGER DEFAULT 0,
task_class_dna TEXT GENERATED ALWAYS AS (
CASE
WHEN dna IS NULL OR dna = '' THEN NULL
WHEN length(dna) > 9
AND substr(dna, length(dna) - 8, 1) = '-'
THEN substr(dna, 1, length(dna) - 9)
ELSE dna
END
) VIRTUAL
);
CREATE INDEX IF NOT EXISTS idx_parent ON agents(parent_branch);
CREATE INDEX IF NOT EXISTS idx_status ON agents(status);
CREATE INDEX IF NOT EXISTS idx_agents_dna_prefix ON agents(substr(dna, 1, 30));
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_dna_unique ON agents(dna);
CREATE INDEX IF NOT EXISTS idx_agents_creator ON agents(creator_id);
CREATE INDEX IF NOT EXISTS idx_agents_fork_parent ON agents(fork_parent_id);
CREATE INDEX IF NOT EXISTS idx_agents_task_class ON agents(task_class_dna);
CREATE TABLE IF NOT EXISTS skill_invocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
skill_name TEXT NOT NULL,
ts INTEGER NOT NULL,
agent_id TEXT,
success INTEGER NOT NULL CHECK(success IN (0, 1)),
trajectory_id TEXT,
duration_ms INTEGER
);
CREATE INDEX IF NOT EXISTS idx_skill_invocations_name_ts
ON skill_invocations(skill_name, ts DESC);
CREATE INDEX IF NOT EXISTS idx_skill_invocations_success
ON skill_invocations(skill_name, success);
COMMIT;