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:
parent
a6948770d1
commit
fb8a004b03
13 changed files with 197 additions and 97 deletions
23
.github/workflows/release.yml
vendored
23
.github/workflows/release.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
29
README.md
29
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 <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
|
||||
|
|
|
|||
2
_ts_packages/package-lock.json
generated
2
_ts_packages/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
install.sh
13
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue