fix(release+slices): v0.14.4 publish auth fallback + 4 fix-implementer slices

After v0.14.3 npm-publish failed again with 401 Unauthorized despite
path-scoped _authToken. Direct curl probe to keigit confirmed BOTH Bearer
and Basic auth schemes work — so the issue is npm 10 not sending the
auth header in CI. Likely cause: deprecated `always-auth=true` interfered
with token resolution.

== Publish auth fix ==
- Drop `always-auth=true` (deprecated in npm 10+; warns in logs)
- Keep path-scoped `_authToken` (npm 10 canonical)
- Add legacy Basic-auth fallback rows (username/_password/email) — Forgejo
  accepts both schemes per direct probe; if one resolution path fails,
  npm tries the other
- chmod 600 on $HOME/.npmrc and project .npmrc (defense-in-depth)
- Bump 0.14.3 → 0.14.4

== Slice A — TS server hardening (Sonnet code-implementer-typescript) ==
File: _ts_packages/packages/mcp-server/src/server.ts (+3/-1)
File: _ts_packages/packages/mcp-server/src/index.ts (+14/-4)
- safeEqual constant-time path on length mismatch (timing oracle close)
- HTTP server defaults to 127.0.0.1 bind; --bind <addr> opt-in for 0.0.0.0
- Body cap 1 MiB with 413 response (DoS prevention)
- VERIFIED: tsc -b --noEmit exit 0

== Slice B — Outcome-only profile hardening (Sonnet code-implementer) ==
Files: install.sh, install/lib-args.sh, install/lib-profile-outcome-only.sh
- Confirm-screen gate before destructive install (skips on --dry-run / --yes)
- _outcome_install_ledger return value tracked → summary reflects reality
  (was: false-success "ledger: ..." when init failed)
- --dry-run silent-ignored on non-outcome profiles → now warns
- VERIFIED: end-to-end smoke against fake $HOME with `<<< "y"` — all 5
  files installed, schema v9 + 2 triggers, summary correct

== Slice D — jq-merge dedup tuple (Sonnet code-implementer) ==
File: install/lib-hooks.sh
- Replaced `unique_by(.command)` with reduce-into-object keyed on
  norm-ed command (tilde-vs-absolute path collision fix)
- Snippet-wins precedence on collision
- 3 manual scenario traces pass: tilde+tilde, absolute+tilde, idempotency

== Slice E — Doc honesty pass (Sonnet code-implementer, selective-merged) ==
Files: README.md, docs/{INSTALL,ARCHITECTURE,PROFILE-OUTCOME-ONLY}.md
Note: Slice E worktree was based on an older main commit; merged
selectively to preserve current-main values (565 DNAs, not worktree's 518)
- README:62 plugin marketplace URL: KeiSei84/KeiSeiKit → KeiSei84/KeiSeiKit-1.0
  (consistent with line 66 git clone URL + Cargo.toml repository field)
- README:9-15: per-claim [REAL: <command>] markers on all 8 numerics
- README:124-132 + PROFILE-OUTCOME-ONLY.md:43-55 + ARCHITECTURE.md:288-302:
  rephrase 100-row router claim — now describes Wilson lower-bound
  (δ=0.10, q*=0.70) continuous metric with file:line pointer to select.rs
- INSTALL.md: ESTIMATE-HTC marker covering all install-time / disk-size
  numerics in profile table (RULE 0.18 compliance)
- PROFILE-OUTCOME-ONLY.md privacy section: discloses agent-toolstats.jsonl
  sidecar (was undocumented per W3 finding)
- PROFILE-OUTCOME-ONLY.md uninstall: added 6th rm -f for .bak-* cleanup
  (closes orphan-accumulation per W3+W4 audits)

[FROM-JOURNAL: tasks.jsonl this session — 12 audit agents waves 5+6 +
4 parallel fix-implementer worktrees ran ~25 min wall-time]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-05-04 00:16:48 +08:00
parent ca99f78f66
commit 8a885a7d76
13 changed files with 197 additions and 97 deletions

View file

@ -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/<pkg>/).
# 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'

View file

@ -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 <repo-url>`).
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 <repo-url>`).
## 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

View file

@ -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",

View file

@ -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",

View file

@ -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<void> {
});
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<void> {
@ -81,11 +86,12 @@ async function dispatchStdioLine(server: McpServer, line: string): Promise<strin
}
}
async function runHttp(server: McpServer, port: number): Promise<void> {
async function runHttp(server: McpServer, port: number, bindAddr: string): Promise<void> {
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;

View file

@ -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);
}

View file

@ -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 +

View file

@ -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=<name>` or one-at-a-time via `--add=<name>`.
> **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 |

View file

@ -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} /<!-- outcome-only profile \(KeiSeiKit\) -->/ {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-<epoch> 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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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 '<!-- outcome-only profile (KeiSeiKit) -->' "$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"
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"
}