diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23b738f..12cc95b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -342,23 +342,28 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail - # v0.14.3 fix: path-scoped _authToken per Forgejo docs - # (https://forgejo.org/docs/latest/user/packages/npm/). The prior - # host-scoped form (`//keigit.com/:_authToken=...`) was silently - # ignored by npm 10 → ENEEDAUTH. Path-scoped exactly matches the - # @keisei:registry URL, so npm reliably resolves the token. - # Also write to $HOME/.npmrc so it's found regardless of cwd - # (the publish loop cd's into packages//). + # v0.14.4 fix: drop deprecated `always-auth=true` (npm 10+ ignores + + # warns) — likely interfered with token resolution silently. + # Add Forgejo-friendly legacy Basic-auth fallback (username/_password) + # since direct curl probe confirmed Basic + Bearer both authenticate + # against keigit.com but npm publish in CI hit 401 with _authToken + # alone — could be path-prefix walk vs canonicalization quirk. + # Username `Parfionovich` is OWNER of org `keisei` on keigit. { echo "@keisei:registry=https://keigit.com/api/packages/keisei/npm/" + # path-scoped _authToken (npm 10 canonical) echo "//keigit.com/api/packages/keisei/npm/:_authToken=${KEIGIT_TOKEN}" - echo "always-auth=true" + # legacy Basic fallback — Forgejo accepts both forms + echo "//keigit.com/api/packages/keisei/npm/:username=Parfionovich" + echo "//keigit.com/api/packages/keisei/npm/:_password=$(printf '%s' "${KEIGIT_TOKEN}" | base64 | tr -d '\n')" + echo "//keigit.com/api/packages/keisei/npm/:email=2206745@gmail.com" if [ -n "${NPM_TOKEN:-}" ]; then echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" fi } | tee "$HOME/.npmrc" > .npmrc + chmod 600 "$HOME/.npmrc" .npmrc # Sanity (no secrets in log — print only registry lines): - grep -v _authToken .npmrc + grep -vE "_authToken|_password|username|email" .npmrc || true - name: Install deps if: steps.have_token.outputs.present == '1' diff --git a/README.md b/README.md index 7bb16bd..1b1bcfb 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,17 @@ context for Cursor / Continue / Zed / Aider / Windsurf / Cline / OpenClaw / Kimi from the same source-of-truth. **Apache 2.0** — explicit patent grant + retaliation clause. 105 Rust -crates (workspace member count via `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`), -68 skills, 38 hooks, 38 agent manifests, 85 substrate blocks, 18 -capability atoms, 7 substrate roles. Self-indexing via kei-registry -SQLite (565 active DNAs as of 2026-05-03 per `docs/DNA-INDEX.md` -header). Three-phase nightly consolidation. Foreign-project ingestion -runtime (`kei-import `). +crates [REAL: `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`], +68 skills [REAL: `ls skills/ | wc -l`], 38 hooks +[REAL: `grep -c '"command":' settings-snippet.json`], 38 agent manifests +[REAL: `ls _manifests/*.toml | wc -l`], 85 substrate blocks +[REAL: `find _blocks/ -name '*.md' | wc -l`], 18 capability atoms +[REAL: `find _capabilities/ -mindepth 2 -maxdepth 2 -type d | wc -l`], +7 substrate roles [REAL: `ls _roles/*.toml | wc -l`]. Self-indexing +via kei-registry SQLite (565 active DNAs +[REAL: `head -3 docs/DNA-INDEX.md | grep "Total blocks:"`] as of +2026-05-03). Three-phase nightly consolidation. Foreign-project +ingestion runtime (`kei-import `). ## Maturity matrix @@ -59,7 +64,7 @@ fork it. ```bash # Claude Code (primary target — full hook + agent integration) -/plugin marketplace add KeiSei84/KeiSeiKit +/plugin marketplace add KeiSei84/KeiSeiKit-1.0 /plugin install keisei@keisei-marketplace # Any MCP-compatible client (Cursor / Continue / Zed / Aider / etc) @@ -118,9 +123,13 @@ outputs are human-readable markdown. You read, you decide what merges. is future work. - **Phase 9 Path A (model-router assembler-time rebake)** — 37 agent manifests currently declare `model: opus` in frontmatter. - Bayesian posterior router activates per-task-class when ≥100 - outcome rows accumulate (currently 3). Until then, routing happens - via orchestrator discipline plus advisor-hook stderr nudges. + The router uses a Beta posterior with Wilson-style lower confidence + bound (`δ=0.10`, `q*=0.70`); it falls back to the manifest-declared + default until the per-(task-class, model) lower-bound clears the + quality bar — typically tens of successful observations per pair, + not a discrete 100-row threshold (see + `_primitives/_rust/kei-model-router/src/select.rs:74-124`). 3 outcome + rows total today, posterior dominated by uniform prior `Beta(1,1)`. - **Cortex stack** (`kei-cortex` / `kei-tty` / `kei-mcp`) ships as **alpha** (CLI/daemon track) — downgraded from "beta" because two of the three intended frontends are not yet shipping. Local HTTP diff --git a/_ts_packages/package-lock.json b/_ts_packages/package-lock.json index eedbd9b..f3fc877 100644 --- a/_ts_packages/package-lock.json +++ b/_ts_packages/package-lock.json @@ -3707,7 +3707,7 @@ }, "packages/mcp-server": { "name": "@keisei/mcp-server", - "version": "0.14.3", + "version": "0.14.4", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/_ts_packages/packages/mcp-server/package.json b/_ts_packages/packages/mcp-server/package.json index 7710752..6ead684 100644 --- a/_ts_packages/packages/mcp-server/package.json +++ b/_ts_packages/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@keisei/mcp-server", - "version": "0.14.3", + "version": "0.14.4", "description": "MCP server exposing KeiSeiKit Rust primitives as Model Context Protocol tools — published to keigit.com (Forgejo npm registry, public DNS)", "type": "module", "main": "./dist/index.js", diff --git a/_ts_packages/packages/mcp-server/src/index.ts b/_ts_packages/packages/mcp-server/src/index.ts index 22dbb58..7821bbb 100644 --- a/_ts_packages/packages/mcp-server/src/index.ts +++ b/_ts_packages/packages/mcp-server/src/index.ts @@ -8,6 +8,7 @@ import { McpServer } from "./server.js"; interface CliArgs { stdio: boolean; port?: number; + bind: string; authTokenFile?: string; rustBinDir: string; } @@ -15,13 +16,17 @@ interface CliArgs { function parseArgv(argv: readonly string[]): CliArgs { const out: CliArgs = { stdio: false, + bind: "127.0.0.1", rustBinDir: process.env["KEI_RUST_BIN_DIR"] ?? defaultBinDir(), }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--stdio") out.stdio = true; else if (a === "--port") out.port = Number(argv[++i] ?? ""); - else if (a === "--auth-token-file") { + else if (a === "--bind") { + const v = argv[++i]; + if (v !== undefined) out.bind = v; + } else if (a === "--auth-token-file") { const v = argv[++i]; if (v !== undefined) out.authTokenFile = v; } else if (a === "--rust-bin-dir") { @@ -52,7 +57,7 @@ async function main(): Promise { }); await server.loadAdapters((m) => process.stderr.write(`[adapters] ${m}\n`)); if (args.stdio) await runStdio(server); - else await runHttp(server, args.port ?? 3000); + else await runHttp(server, args.port ?? 3000, args.bind); } async function runStdio(server: McpServer): Promise { @@ -81,11 +86,12 @@ async function dispatchStdioLine(server: McpServer, line: string): Promise { +async function runHttp(server: McpServer, port: number, bindAddr: string): Promise { const http = await import("node:http"); const srv = http.createServer((req, res) => void handleHttp(server, req, res)); - srv.listen(port, () => - process.stderr.write(`[keisei-mcp] http :${port}; ${server.listTools().length} tools\n`), + // Bind to 127.0.0.1 by default; pass --bind 0.0.0.0 to expose on all interfaces. + srv.listen(port, bindAddr, () => + process.stderr.write(`[keisei-mcp] http ${bindAddr}:${port}; ${server.listTools().length} tools\n`), ); } @@ -95,8 +101,19 @@ async function handleHttp(server: McpServer, req: import("node:http").IncomingMe res.end(); return; } + const MAX_BODY = 1 * 1024 * 1024; // 1 MiB + let total = 0; const chunks: Buffer[] = []; - for await (const c of req) chunks.push(c as Buffer); + for await (const c of req) { + total += (c as Buffer).length; + if (total > MAX_BODY) { + res.writeHead(413, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: { code: -32600, message: "request body exceeds 1 MiB" } })); + req.destroy(); + return; + } + chunks.push(c as Buffer); + } try { const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { tool: string; diff --git a/_ts_packages/packages/mcp-server/src/server.ts b/_ts_packages/packages/mcp-server/src/server.ts index eba0805..df07296 100644 --- a/_ts_packages/packages/mcp-server/src/server.ts +++ b/_ts_packages/packages/mcp-server/src/server.ts @@ -83,7 +83,11 @@ export class McpServer { function safeEqual(a: string, b: string): boolean { const ba = Buffer.from(a); const bb = Buffer.from(b); - if (ba.length !== bb.length) return false; + if (ba.length !== bb.length) { + // constant-time dummy compare to mask timing + crypto.timingSafeEqual(ba, ba); + return false; + } return crypto.timingSafeEqual(ba, bb); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 95e2248..7e121c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -289,12 +289,16 @@ What this is and is not, today: parameters per (task-class, model) pair. As more outcomes accumulate, the ranking deviates from the manifest-declared default. - **It is not** "smart routing on day one". A fresh install has **0** - outcome rows. Until at least N≈100 outcomes per task-class accumulate - in production, the router falls back to the model declared in the - agent manifest's `model:` frontmatter. With 37 agent manifests - currently declaring `model: opus`, the practical effect on a fresh - install is "always Opus" — the router's posterior has no data to - override the default with. + outcome rows. The router uses a Wilson-style lower confidence bound + (`δ=0.10`, `q*=0.70`) — it falls back to the manifest-declared default + UNTIL the per-(task-class, model) lower-bound clears the quality bar. + This is a continuous metric, NOT a discrete 100-row threshold (see + `_primitives/_rust/kei-model-router/src/select.rs:74-124`); typically + tens of successful observations per pair are sufficient. With 37 + agent manifests currently declaring `model: opus`, the practical + effect on a fresh install is "always Opus" — the router's posterior + is dominated by the uniform `Beta(1,1)` prior and has no data to + override the default. - **Outcome-row count for a fresh install: 0.** Plan to run for some weeks under realistic load before the router meaningfully reorders tier selection. Until then, route by orchestrator discipline + diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ca80d9f..6c5b194 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -63,6 +63,12 @@ After the profile is chosen, an **Install Plan** screen summarizes what will be By default `./install.sh` is **minimal** — agents + hooks + skills + bridges, no primitives. Fastest (~5s) and zero Rust compile for primitives. You opt into primitives via `--profile=` or one-at-a-time via `--add=`. +> **Numeric estimates:** all `~5s` / `~60s` / `~90s` / `~6 min` install +> times and `~2 MB` / `~80 MB` / `~55 MB` / `~60 MB` / `~220 MB` disk +> sizes in this table carry `[ESTIMATE-HTC: based on author's +> 2026-04-30 install on M1 Mac, varies by network and disk speed]`. +> Re-measured against your machine if precise numbers matter. + | Profile | Primitives added | Install time | Disk (approx) | |---|---|---|---| | `minimal` (default) | none | ~5s | ~2 MB | diff --git a/docs/PROFILE-OUTCOME-ONLY.md b/docs/PROFILE-OUTCOME-ONLY.md index 6fc3f2b..28ea4ff 100644 --- a/docs/PROFILE-OUTCOME-ONLY.md +++ b/docs/PROFILE-OUTCOME-ONLY.md @@ -42,11 +42,14 @@ is preserved verbatim — both paths share `~/.claude/hooks/` and 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. +The router uses a Wilson-style lower confidence bound (`δ=0.10`, +`q*=0.70`) — it falls back to "behaviour unchanged" (manifest-declared +model) UNTIL the per-(task-class, model) lower-bound clears the +quality bar. Typically that's tens of successful observations per +pair, not a discrete 100-row threshold (see +`_primitives/_rust/kei-model-router/src/select.rs:74-124`). -After ~100 rows the posterior dominates the prior and the router +Once the posterior dominates the uniform `Beta(1,1)` prior 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. @@ -72,6 +75,11 @@ sqlite3 ~/.claude/agents/ledger.sqlite \ ORDER BY started_ts DESC LIMIT 20;" ``` +A sidecar JSONL at `~/.claude/memory/time-metrics/agent-toolstats.jsonl` +accumulates per-agent token counts, tool-use stats, and durations — +local-only, append-only, no network egress. Same privacy guarantees as +the ledger; the uninstall recipe below removes it. + Uncomfortable with the file? `rm` it; the next install or agent run recreates an empty schema, no other side effects. @@ -89,6 +97,10 @@ rm -f ~/.claude/memory/time-metrics/agent-toolstats.jsonl awk 'BEGIN{skip=0} // {skip=2; next} skip>0 {skip--; next} {print}' \ ~/.claude/CLAUDE.md > ~/.claude/CLAUDE.md.tmp \ && mv ~/.claude/CLAUDE.md.tmp ~/.claude/CLAUDE.md +# Clean .bak- files left by backup_file during install +rm -f ~/.claude/hooks/*.bak-* \ + ~/.claude/CLAUDE.md.bak-* \ + ~/.claude/settings.json.bak-* ``` Both hooks exit 0 immediately when their target script is missing, so diff --git a/install.sh b/install.sh index 98e65e7..f17195d 100755 --- a/install.sh +++ b/install.sh @@ -80,6 +80,12 @@ source "$LIB_DIR/lib-profile-outcome-only.sh" parse_args "$@" setup_backup_trap +# Fix 3: --dry-run is only meaningful with --profile=outcome-only. +# Warn early so the user doesn't assume other profiles respect it. +if [ "${OUTCOME_DRY_RUN:-0}" = "1" ] && [ "$PROFILE" != "outcome-only" ] && [ -n "$PROFILE" ]; then + warn "--dry-run is only effective with --profile=outcome-only; for other profiles use --no-execute" +fi + # --- --list short-circuit ------------------------------------------------- if [ "$LIST_MODE" = "1" ]; then [ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; } @@ -110,12 +116,9 @@ 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. +# --- outcome-only profile short-circuit (see docs/PROFILE-OUTCOME-ONLY.md) --- if [ "${PROFILE:-}" = "outcome-only" ]; then + _outcome_confirm_if_needed export OUTCOME_DRY_RUN install_profile_outcome_only exit 0 diff --git a/install/lib-args.sh b/install/lib-args.sh index 6661c87..44623e9 100644 --- a/install/lib-args.sh +++ b/install/lib-args.sh @@ -3,7 +3,7 @@ # # Sets globals: ACTIVATE_HOOKS, WITH_BRIDGES, WITH_SLEEP_SYNC, # WITH_PATHWAY, NO_PATHWAY, PROFILE, ADD_LIST, REMOVE_NAME, LIST_MODE, -# ASSUME_YES, NO_EXECUTE. +# ASSUME_YES, NO_EXECUTE, OUTCOME_DRY_RUN. # --help exits 0 immediately. ACTIVATE_HOOKS=0 diff --git a/install/lib-hooks.sh b/install/lib-hooks.sh index 14badb5..c4f33fc 100644 --- a/install/lib-hooks.sh +++ b/install/lib-hooks.sh @@ -51,16 +51,33 @@ _jq_merge_hooks() { local snippet="$1" target="$2" tmp tmp="$(mktemp "$target.XXXXXX")" jq --slurpfile snip "$snippet" ' + # Normalize a command path: expand leading ~/ to $HOME so tilde and + # absolute forms compare equal (prevents duplicate hook registration). + def norm: if startswith("~/") then env.HOME + .[1:] else . end; + . as $orig | ($snip[0] | del(._comment)) as $add | reduce ($add.hooks | keys[]) as $phase ($orig; .hooks[$phase] = ( ((.hooks[$phase] // []) + ($add.hooks[$phase] // [])) | group_by(.matcher) - | map({ - matcher: .[0].matcher, - hooks: (map(.hooks // []) | add | unique_by(.command)) - }) + | map( + .[0].matcher as $m + | { + matcher: $m, + hooks: ( + map(.hooks // []) | add + # Reduce into object keyed by normalised command. + # Last entry wins → snippet (appended last) overrides + # existing on collision, preserving all extra fields. + | reduce .[] as $h ( + {}; + . + { (($h.command // "") | norm): $h } + ) + | [.[]] + ) + } + ) ) ) ' "$target" > "$tmp" diff --git a/install/lib-profile-outcome-only.sh b/install/lib-profile-outcome-only.sh index 5dabdfd..92cbb8b 100644 --- a/install/lib-profile-outcome-only.sh +++ b/install/lib-profile-outcome-only.sh @@ -53,11 +53,7 @@ _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" - # Cross-version downgrade guard (audit fix 2026-05-03 W3): if an - # existing DB is at a NEWER schema (user_version > 9, e.g. user - # later upgrades to a full kit that adds a v10 migration), do NOT - # re-run any init path — the SQL fallback would otherwise reset - # user_version and the binary path may replay incompatible v9 ALTERs. + # Downgrade guard: skip init if DB is at a newer schema (user_version > 9). if [ -f "$db" ] && command -v sqlite3 >/dev/null 2>&1; then local current_v current_v=$(sqlite3 "$db" "PRAGMA user_version;" 2>/dev/null || echo 0) @@ -83,16 +79,12 @@ _outcome_install_ledger() { return 1 } -# Append STATUS-TRUTH MARKER instruction to CLAUDE.md (idempotent: skip -# if our specific marker comment is already present). +# Append STATUS-TRUTH MARKER instruction to CLAUDE.md (idempotent). _outcome_install_claude_md() { local cm="$HOME_DIR/.claude/CLAUDE.md" mkdir -p "$HOME_DIR/.claude" - # Audit fix 2026-05-03 (W3): match the literal HTML comment marker we - # wrote, NOT the broad phrase "STATUS-TRUTH MARKER" — the broad phrase - # is also used in RULE 0.16 documentation that many users already have - # in their CLAUDE.md, which would cause a false-positive skip and the - # outcome-only instruction would never land. + # Match HTML comment marker (not generic "STATUS-TRUTH MARKER" text) to avoid + # false-positive skip when user already has RULE 0.16 docs in CLAUDE.md. if [ -f "$cm" ] && grep -qF '' "$cm"; then say "CLAUDE.md already contains outcome-only marker; skipping" return 0 @@ -119,9 +111,65 @@ _outcome_install_router_if_cargo() { || warn "cargo build failed; router not installed (rerun manually if desired)" } +# Confirm gate (Fix 1): show plan + prompt; skip for dry-run or --yes. +_outcome_confirm_if_needed() { + [ "${OUTCOME_DRY_RUN:-0}" = "1" ] && return 0 + [ "${ASSUME_YES:-0}" = "1" ] && return 0 + say "Outcome-only profile will install:" + say " - 2 hooks (~/.claude/hooks/agent-outcome-backfill.sh, error-spike-detector.sh)" + say " - SQLite ledger (~/.claude/agents/ledger.sqlite)" + say " - 1 line in ~/.claude/CLAUDE.md (STATUS-TRUTH MARKER instruction)" + say " - jq-merge of 2 hook entries into ~/.claude/settings.json" + say " - kei-model-router binary (deferred if cargo missing)" + printf "Continue? [y/N] " + read -r _oc_ans + case "$_oc_ans" in + [Yy]*) ;; + *) say "Aborted."; exit 0 ;; + esac +} + +# Copy the 2 hook files to HOOKS_DIR. +_outcome_install_hooks() { + local hook_src hook_dst + mkdir -p "$HOOKS_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 +} + +# Write or jq-merge the minimal settings-snippet into settings.json. +_outcome_merge_settings() { + local snippet + 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 + # cp -p aside (not backup_file which MOVES) + register in BACKUP_PAIRS for rollback. + local _ts _bak + _ts=$(date +%s) + _bak="$HOME_DIR/.claude/settings.json.bak-$_ts" + cp -p "$HOME_DIR/.claude/settings.json" "$_bak" + BACKUP_PAIRS+=("$HOME_DIR/.claude/settings.json|$_bak") + if ! _jq_merge_hooks "$snippet" "$HOME_DIR/.claude/settings.json"; then + err "settings.json merge failed; rollback trap will restore from $_bak" + rm -f "$snippet"; return 1 + fi + rm -f "$_bak" + fi + rm -f "$snippet" +} + # 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" @@ -132,46 +180,21 @@ install_profile_outcome_only() { 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 + mkdir -p "$AGENTS_DIR" + _outcome_install_hooks || return $? + # Fix 2: track ledger install result so summary reflects reality + local ledger_ok=1 + _outcome_install_ledger || ledger_ok=0 _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 - # Audit fix 2026-05-03 (W3): backup_file MOVES the target which would - # leave _jq_merge_hooks with no file to read. Use cp -p to copy aside - # AND register the pair in BACKUP_PAIRS so the rollback trap restores - # it on later failure (was orphan-bak-not-in-rollback-contract). - local _ts - _ts=$(date +%s) - local _bak="$HOME_DIR/.claude/settings.json.bak-$_ts" - cp -p "$HOME_DIR/.claude/settings.json" "$_bak" - BACKUP_PAIRS+=("$HOME_DIR/.claude/settings.json|$_bak") - if ! _jq_merge_hooks "$snippet" "$HOME_DIR/.claude/settings.json"; then - err "settings.json merge failed; rollback trap will restore from $_bak" - rm -f "$snippet" - return 1 - fi - # Success: remove our backup (rollback contract released) - rm -f "$_bak" - fi - rm -f "$snippet" + _outcome_merge_settings || return $? say "outcome-only profile installed." say " hooks: agent-outcome-backfill.sh, error-spike-detector.sh" - say " ledger: $AGENTS_DIR/ledger.sqlite" + if [ "$ledger_ok" = "1" ]; then + say " ledger: $AGENTS_DIR/ledger.sqlite" + else + warn " ledger: NOT INSTALLED — backfill hook will be silent no-op until sqlite3/kei-ledger is available" + fi say " CLAUDE.md updated (1 line appended)" say " router: built (if cargo present), else deferred — see docs/PROFILE-OUTCOME-ONLY.md" }