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 }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
# v0.14.3 fix: path-scoped _authToken per Forgejo docs # v0.14.4 fix: drop deprecated `always-auth=true` (npm 10+ ignores +
# (https://forgejo.org/docs/latest/user/packages/npm/). The prior # warns) — likely interfered with token resolution silently.
# host-scoped form (`//keigit.com/:_authToken=...`) was silently # Add Forgejo-friendly legacy Basic-auth fallback (username/_password)
# ignored by npm 10 → ENEEDAUTH. Path-scoped exactly matches the # since direct curl probe confirmed Basic + Bearer both authenticate
# @keisei:registry URL, so npm reliably resolves the token. # against keigit.com but npm publish in CI hit 401 with _authToken
# Also write to $HOME/.npmrc so it's found regardless of cwd # alone — could be path-prefix walk vs canonicalization quirk.
# (the publish loop cd's into packages/<pkg>/). # Username `Parfionovich` is OWNER of org `keisei` on keigit.
{ {
echo "@keisei:registry=https://keigit.com/api/packages/keisei/npm/" 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 "//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 if [ -n "${NPM_TOKEN:-}" ]; then
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}"
fi fi
} | tee "$HOME/.npmrc" > .npmrc } | tee "$HOME/.npmrc" > .npmrc
chmod 600 "$HOME/.npmrc" .npmrc
# Sanity (no secrets in log — print only registry lines): # Sanity (no secrets in log — print only registry lines):
grep -v _authToken .npmrc grep -vE "_authToken|_password|username|email" .npmrc || true
- name: Install deps - name: Install deps
if: steps.have_token.outputs.present == '1' 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. 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 (workspace member count via `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`), crates [REAL: `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`],
68 skills, 38 hooks, 38 agent manifests, 85 substrate blocks, 18 68 skills [REAL: `ls skills/ | wc -l`], 38 hooks
capability atoms, 7 substrate roles. Self-indexing via kei-registry [REAL: `grep -c '"command":' settings-snippet.json`], 38 agent manifests
SQLite (565 active DNAs as of 2026-05-03 per `docs/DNA-INDEX.md` [REAL: `ls _manifests/*.toml | wc -l`], 85 substrate blocks
header). Three-phase nightly consolidation. Foreign-project ingestion [REAL: `find _blocks/ -name '*.md' | wc -l`], 18 capability atoms
runtime (`kei-import <repo-url>`). [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 ## Maturity matrix
@ -59,7 +64,7 @@ fork it.
```bash ```bash
# Claude Code (primary target — full hook + agent integration) # 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 /plugin install keisei@keisei-marketplace
# Any MCP-compatible client (Cursor / Continue / Zed / Aider / etc) # 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. is future work.
- **Phase 9 Path A (model-router assembler-time rebake)** - **Phase 9 Path A (model-router assembler-time rebake)**
37 agent manifests currently declare `model: opus` in frontmatter. 37 agent manifests currently declare `model: opus` in frontmatter.
Bayesian posterior router activates per-task-class when ≥100 The router uses a Beta posterior with Wilson-style lower confidence
outcome rows accumulate (currently 3). Until then, routing happens bound (`δ=0.10`, `q*=0.70`); it falls back to the manifest-declared
via orchestrator discipline plus advisor-hook stderr nudges. 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 - **Cortex stack** (`kei-cortex` / `kei-tty` / `kei-mcp`) ships as
**alpha** (CLI/daemon track) — downgraded from "beta" because two **alpha** (CLI/daemon track) — downgraded from "beta" because two
of the three intended frontends are not yet shipping. Local HTTP of the three intended frontends are not yet shipping. Local HTTP

View file

@ -3707,7 +3707,7 @@
}, },
"packages/mcp-server": { "packages/mcp-server": {
"name": "@keisei/mcp-server", "name": "@keisei/mcp-server",
"version": "0.14.3", "version": "0.14.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "@keisei/mcp-server", "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)", "description": "MCP server exposing KeiSeiKit Rust primitives as Model Context Protocol tools — published to keigit.com (Forgejo npm registry, public DNS)",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View file

@ -8,6 +8,7 @@ import { McpServer } from "./server.js";
interface CliArgs { interface CliArgs {
stdio: boolean; stdio: boolean;
port?: number; port?: number;
bind: string;
authTokenFile?: string; authTokenFile?: string;
rustBinDir: string; rustBinDir: string;
} }
@ -15,13 +16,17 @@ interface CliArgs {
function parseArgv(argv: readonly string[]): CliArgs { function parseArgv(argv: readonly string[]): CliArgs {
const out: CliArgs = { const out: CliArgs = {
stdio: false, stdio: false,
bind: "127.0.0.1",
rustBinDir: process.env["KEI_RUST_BIN_DIR"] ?? defaultBinDir(), rustBinDir: process.env["KEI_RUST_BIN_DIR"] ?? defaultBinDir(),
}; };
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const a = argv[i]; const a = argv[i];
if (a === "--stdio") out.stdio = true; if (a === "--stdio") out.stdio = true;
else if (a === "--port") out.port = Number(argv[++i] ?? ""); 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]; const v = argv[++i];
if (v !== undefined) out.authTokenFile = v; if (v !== undefined) out.authTokenFile = v;
} else if (a === "--rust-bin-dir") { } 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`)); await server.loadAdapters((m) => process.stderr.write(`[adapters] ${m}\n`));
if (args.stdio) await runStdio(server); 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> { 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 http = await import("node:http");
const srv = http.createServer((req, res) => void handleHttp(server, req, res)); const srv = http.createServer((req, res) => void handleHttp(server, req, res));
srv.listen(port, () => // Bind to 127.0.0.1 by default; pass --bind 0.0.0.0 to expose on all interfaces.
process.stderr.write(`[keisei-mcp] http :${port}; ${server.listTools().length} tools\n`), 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(); res.end();
return; return;
} }
const MAX_BODY = 1 * 1024 * 1024; // 1 MiB
let total = 0;
const chunks: Buffer[] = []; 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 { try {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
tool: string; tool: string;

View file

@ -83,7 +83,11 @@ export class McpServer {
function safeEqual(a: string, b: string): boolean { function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a); const ba = Buffer.from(a);
const bb = Buffer.from(b); 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); 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, parameters per (task-class, model) pair. As more outcomes accumulate,
the ranking deviates from the manifest-declared default. the ranking deviates from the manifest-declared default.
- **It is not** "smart routing on day one". A fresh install has **0** - **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 outcome rows. The router uses a Wilson-style lower confidence bound
in production, the router falls back to the model declared in the (`δ=0.10`, `q*=0.70`) — it falls back to the manifest-declared default
agent manifest's `model:` frontmatter. With 37 agent manifests UNTIL the per-(task-class, model) lower-bound clears the quality bar.
currently declaring `model: opus`, the practical effect on a fresh This is a continuous metric, NOT a discrete 100-row threshold (see
install is "always Opus" — the router's posterior has no data to `_primitives/_rust/kei-model-router/src/select.rs:74-124`); typically
override the default with. 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 - **Outcome-row count for a fresh install: 0.** Plan to run for some
weeks under realistic load before the router meaningfully reorders weeks under realistic load before the router meaningfully reorders
tier selection. Until then, route by orchestrator discipline + 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>`. 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) | | Profile | Primitives added | Install time | Disk (approx) |
|---|---|---|---| |---|---|---|---|
| `minimal` (default) | none | ~5s | ~2 MB | | `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 The router is a posterior decision rule keyed on per-task-class DNA
plus a Beta posterior over `(success, total)` in `agents.outcome`. plus a Beta posterior over `(success, total)` in `agents.outcome`.
Until you accumulate ~100 outcome rows, the router falls back to The router uses a Wilson-style lower confidence bound (`δ=0.10`,
"behaviour unchanged" — every spawn keeps whatever model the agent `q*=0.70`) — it falls back to "behaviour unchanged" (manifest-declared
manifest declares. 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 starts producing concrete recommendations. You opt in by adding
`kei-model-router` to a `PreToolUse:Agent` hook later — that step is `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. **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;" 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 Uncomfortable with the file? `rm` it; the next install or agent run
recreates an empty schema, no other side effects. 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}' \ awk 'BEGIN{skip=0} /<!-- outcome-only profile \(KeiSeiKit\) -->/ {skip=2; next} skip>0 {skip--; next} {print}' \
~/.claude/CLAUDE.md > ~/.claude/CLAUDE.md.tmp \ ~/.claude/CLAUDE.md > ~/.claude/CLAUDE.md.tmp \
&& mv ~/.claude/CLAUDE.md.tmp ~/.claude/CLAUDE.md && 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 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 "$@" parse_args "$@"
setup_backup_trap 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 ------------------------------------------------- # --- --list short-circuit -------------------------------------------------
if [ "$LIST_MODE" = "1" ]; then if [ "$LIST_MODE" = "1" ]; then
[ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; } [ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; }
@ -110,12 +116,9 @@ if [ -n "$ADD_LIST" ] || [ -n "$REMOVE_NAME" ]; then
exit 0 exit 0
fi fi
# --- outcome-only profile short-circuit ---------------------------------- # --- outcome-only profile short-circuit (see docs/PROFILE-OUTCOME-ONLY.md) ---
# 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 if [ "${PROFILE:-}" = "outcome-only" ]; then
_outcome_confirm_if_needed
export OUTCOME_DRY_RUN export OUTCOME_DRY_RUN
install_profile_outcome_only install_profile_outcome_only
exit 0 exit 0

View file

@ -3,7 +3,7 @@
# #
# Sets globals: ACTIVATE_HOOKS, WITH_BRIDGES, WITH_SLEEP_SYNC, # Sets globals: ACTIVATE_HOOKS, WITH_BRIDGES, WITH_SLEEP_SYNC,
# WITH_PATHWAY, NO_PATHWAY, PROFILE, ADD_LIST, REMOVE_NAME, LIST_MODE, # 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. # --help exits 0 immediately.
ACTIVATE_HOOKS=0 ACTIVATE_HOOKS=0

View file

@ -51,16 +51,33 @@ _jq_merge_hooks() {
local snippet="$1" target="$2" tmp local snippet="$1" target="$2" tmp
tmp="$(mktemp "$target.XXXXXX")" tmp="$(mktemp "$target.XXXXXX")"
jq --slurpfile snip "$snippet" ' 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 . as $orig
| ($snip[0] | del(._comment)) as $add | ($snip[0] | del(._comment)) as $add
| 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] // []))
| group_by(.matcher) | group_by(.matcher)
| map({ | map(
matcher: .[0].matcher, .[0].matcher as $m
hooks: (map(.hooks // []) | add | unique_by(.command)) | {
}) 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" ' "$target" > "$tmp"

View file

@ -53,11 +53,7 @@ _outcome_install_ledger() {
local db="$AGENTS_DIR/ledger.sqlite" local db="$AGENTS_DIR/ledger.sqlite"
mkdir -p "$AGENTS_DIR" mkdir -p "$AGENTS_DIR"
local kl="$KIT_DIR/_primitives/_rust/kei-ledger/target/release/kei-ledger" local kl="$KIT_DIR/_primitives/_rust/kei-ledger/target/release/kei-ledger"
# Cross-version downgrade guard (audit fix 2026-05-03 W3): if an # Downgrade guard: skip init if DB is at a newer schema (user_version > 9).
# 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.
if [ -f "$db" ] && command -v sqlite3 >/dev/null 2>&1; then if [ -f "$db" ] && command -v sqlite3 >/dev/null 2>&1; then
local current_v local current_v
current_v=$(sqlite3 "$db" "PRAGMA user_version;" 2>/dev/null || echo 0) current_v=$(sqlite3 "$db" "PRAGMA user_version;" 2>/dev/null || echo 0)
@ -83,16 +79,12 @@ _outcome_install_ledger() {
return 1 return 1
} }
# Append STATUS-TRUTH MARKER instruction to CLAUDE.md (idempotent: skip # Append STATUS-TRUTH MARKER instruction to CLAUDE.md (idempotent).
# if our specific marker comment is already present).
_outcome_install_claude_md() { _outcome_install_claude_md() {
local cm="$HOME_DIR/.claude/CLAUDE.md" local cm="$HOME_DIR/.claude/CLAUDE.md"
mkdir -p "$HOME_DIR/.claude" mkdir -p "$HOME_DIR/.claude"
# Audit fix 2026-05-03 (W3): match the literal HTML comment marker we # Match HTML comment marker (not generic "STATUS-TRUTH MARKER" text) to avoid
# wrote, NOT the broad phrase "STATUS-TRUTH MARKER" — the broad phrase # false-positive skip when user already has RULE 0.16 docs in CLAUDE.md.
# 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.
if [ -f "$cm" ] && grep -qF '<!-- outcome-only profile (KeiSeiKit) -->' "$cm"; then if [ -f "$cm" ] && grep -qF '<!-- outcome-only profile (KeiSeiKit) -->' "$cm"; then
say "CLAUDE.md already contains outcome-only marker; skipping" say "CLAUDE.md already contains outcome-only marker; skipping"
return 0 return 0
@ -119,9 +111,65 @@ _outcome_install_router_if_cargo() {
|| warn "cargo build failed; router not installed (rerun manually if desired)" || 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. # Public entry — called from install.sh when --profile=outcome-only.
install_profile_outcome_only() { install_profile_outcome_only() {
local hook_src hook_dst snippet
if [ "${OUTCOME_DRY_RUN:-0}" = "1" ]; then if [ "${OUTCOME_DRY_RUN:-0}" = "1" ]; then
_outcome_dr_add "$HOOKS_DIR/agent-outcome-backfill.sh" _outcome_dr_add "$HOOKS_DIR/agent-outcome-backfill.sh"
_outcome_dr_add "$HOOKS_DIR/error-spike-detector.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 printf '%s' "$OUTCOME_DRY_RUN_FILES" | sed '/^$/d' | nl -ba
return 0 return 0
fi fi
mkdir -p "$HOOKS_DIR" "$AGENTS_DIR" mkdir -p "$AGENTS_DIR"
for hook_src in \ _outcome_install_hooks || return $?
"$KIT_DIR/hooks/agent-outcome-backfill.sh" \ # Fix 2: track ledger install result so summary reflects reality
"$KIT_DIR/hooks/error-spike-detector.sh" ; do local ledger_ok=1
[ -f "$hook_src" ] || { err "missing source hook: $hook_src"; return 2; } _outcome_install_ledger || ledger_ok=0
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_claude_md
_outcome_install_router_if_cargo _outcome_install_router_if_cargo
snippet="$(mktemp -t outcome-snippet.XXXXXX)" _outcome_merge_settings || return $?
_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"
say "outcome-only profile installed." say "outcome-only profile installed."
say " hooks: agent-outcome-backfill.sh, error-spike-detector.sh" 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 " CLAUDE.md updated (1 line appended)"
say " router: built (if cargo present), else deferred — see docs/PROFILE-OUTCOME-ONLY.md" say " router: built (if cargo present), else deferred — see docs/PROFILE-OUTCOME-ONLY.md"
} }