diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6387e2bb1..11da45ec5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -317,9 +317,9 @@ jobs:
# linux-x64 + linux-arm64 + darwin-arm64 — every pin machine-gated (spec §5).
# ubuntu-24.04-arm is the free GitHub-hosted arm runner (public repo), and it
# is the ONLY place a linux-arm64 binary is executed.
- # macos-14 boot-smoke: the binary is cross-compiled from linux; native
+ # macos-15 boot-smoke: the binary is cross-compiled from linux; native
# execution on macOS is the safety net.
- os: [ubuntu-latest, ubuntu-24.04-arm, macos-14]
+ os: [ubuntu-latest, ubuntu-24.04-arm, macos-15]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
diff --git a/.gitignore b/.gitignore
index e0931a893..4c5ac985e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,4 +13,5 @@ packages/extension/bin/
# personal local dev config — machine-specific, not for the container
opencode.json
-ops/hub/__pycache__/
+# python bytecode, anywhere (#1943)
+__pycache__/
diff --git a/.notturno/gates b/.notturno/gates
new file mode 100755
index 000000000..54c1d8f4d
--- /dev/null
+++ b/.notturno/gates
@@ -0,0 +1,379 @@
+#!/usr/bin/env bash
+# .notturno/gates — the night runner's pre-push gate for amicode (#1980).
+#
+# harmoniqs/amicissimo automation/notturno/runner/night-run.sh runs this
+# (cwd = repo root, inside a Modal sandbox with NO vault and NO fleet) after
+# the headless implementer commits; a non-zero exit reports `gates-red`
+# instead of opening a red PR. The runner surfaces the LAST lines of the log,
+# so the final line is always a one-line summary.
+#
+# It runs the cheap, deterministic, environment-independent subset of CI
+# (.github/workflows/ci.yml):
+# drift node packages/app-bundle/scripts/drift_gate.mjs (app-bundle-gate)
+# typecheck pnpm -r run typecheck (fast)
+# vitest the vitest suites of the packages the diff touched (fast), vs
+# the merge-base with the base branch. amico-run goes through the
+# same test-quarantine gate CI uses (#1907/#1939); every suite
+# runs with CI's `--exclude '**/slow/**'`, plus the real-SSH
+# loopback suites below (fleet-dependent; they self-skip without
+# an sshd and mutate ~/.ssh/authorized_keys when one exists).
+# Vault-dependent tests self-skip on their own existsSync/env
+# guards, exactly as in CI. A `pnpm -r run build` (step `build`)
+# precedes the suites, as in CI (several suites read built bundles).
+# The suites run in CI's environment (#2007), not the sandbox's:
+# a scrubbed env (`env -i` + PATH/TMPDIR/locale/USER) with CI=true,
+# GITHUB_ACTIONS=true, RUNNER_TEMP, GITHUB_BASE_REF= (a
+# pull_request run), TZ=UTC, and a fresh EMPTY scratch HOME — the
+# GitHub runner's HOME has no ~/.amico, no ~/.config/opencode, no
+# fleet; the sandbox's HOME has the implementer's opencode config
+# and the env carries AWS/GH tokens + NOTTURNO_*, which tests read.
+# A red suite names its failing test files (first 5) in RESULT:
+# failed: vitest:extension [test/a.test.ts, test/b.test.ts]
+# As root (the Modal sandbox) the suites DROP to an unprivileged user
+# (#2021): CI's runner is not root, and chmod-000/0500 permission
+# tests cannot fail for root. The tree + scratch are chowned to a
+# `notturno-gates` user for the vitest steps (restored on exit) and
+# the suites run via setpriv/runuser. NOTTURNO_GATES_DROP_ROOT=0 opts out.
+# Divergences the gate cannot erase are printed as `warn:` lines:
+# node major != CI's 20, still root (drop unavailable/disabled),
+# CI-runner tools missing from the image (zip, rsync, shasum).
+# NOT here (need the vendored engine binary / bun materialization / Julia /
+# PyPI): build-binary, engine-tests, vsix-gate, boot-smoke, schema-roundtrip.
+#
+# Usage: ./.notturno/gates run the gates
+# ./.notturno/gates --plan print base, merge-base, selected packages; run nothing
+# Env:
+# NOTTURNO_BASE base branch (e.g. lane/0.3.11 or origin/lane/0.3.11).
+# Unset/unresolvable → the nearest of origin/lane/* and
+# origin/main (fewest commits from merge-base to HEAD).
+# No base at all → every package's suite runs.
+# NOTTURNO_GATES_STEPS comma list subset of drift,typecheck,build,vitest (default all;
+# build only runs when vitest has packages to test)
+# NOTTURNO_GATES_HERMETIC=0 run the suites in the ambient env/HOME (debugging only)
+# NOTTURNO_GATES_DROP_ROOT=0 keep root for the vitest steps (debugging only)
+# NOTTURNO_GATES_USER the unprivileged user (default notturno-gates; created if absent)
+# NOTTURNO_GATES_DROP_TOOLS privilege-drop tools to try, in order (default: setpriv runuser; tests only)
+# NOTTURNO_GATES_EUID pretend the effective uid is this (tests only)
+# NOTTURNO_GATES_CI_TOOLS tools CI's runner has that the suites spawn (default: zip rsync shasum)
+set -uo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT" || { echo "[notturno-gates] RESULT: RED — cannot cd to repo root $ROOT"; exit 2; }
+
+say() { echo "[notturno-gates] $*"; }
+
+# Fleet-dependent suites (real loopback SSH), excluded by basename glob.
+ENV_DEPENDENT_EXCLUDES="**/fleet_peer_e2e.test.ts **/amicode_service_attachment_transport.test.ts **/amicode_service_attach_switch_latency.test.ts"
+
+PLAN=0
+for a in "$@"; do
+ case "$a" in
+ --plan) PLAN=1 ;;
+ -h|--help) sed -n '2,57p' "$0"; exit 0 ;;
+ *) echo "[notturno-gates] RESULT: RED — unknown argument: $a"; exit 2 ;;
+ esac
+done
+
+STEPS="${NOTTURNO_GATES_STEPS:-drift,typecheck,build,vitest}"
+for s in ${STEPS//,/ }; do
+ case "$s" in
+ drift|typecheck|build|vitest) ;;
+ *) echo "[notturno-gates] RESULT: RED — unknown step '$s' in NOTTURNO_GATES_STEPS (known: drift,typecheck,build,vitest)"; exit 2 ;;
+ esac
+done
+want() { case ",$STEPS," in *",$1,"*) return 0 ;; *) return 1 ;; esac; }
+
+# ── base + merge-base ────────────────────────────────────────────────────────
+is_commit() { git rev-parse --verify -q "$1^{commit}" >/dev/null 2>&1; }
+
+resolve_base() {
+ local b="${NOTTURNO_BASE:-}" c
+ if [ -n "$b" ]; then
+ # prefer the remote-tracking ref for a bare branch name (a local branch may be stale);
+ # HEAD-relative refs are taken as given (origin/HEAD would silently mean main)
+ case "$b" in HEAD|HEAD~*|HEAD^*|@*) set -- "$b" ;; *) set -- "origin/${b#origin/}" "$b" ;; esac
+ for c in "$@"; do
+ if is_commit "$c"; then echo "$c"; return 0; fi
+ done
+ say "warn: NOTTURNO_BASE='$b' does not resolve — falling back to origin/lane/* | origin/main" >&2
+ fi
+ local best="" best_n="" mb n
+ for c in $(git for-each-ref --sort=-version:refname --format='%(refname:short)' 'refs/remotes/origin/lane/' 2>/dev/null) origin/main; do
+ is_commit "$c" || continue
+ mb="$(git merge-base "$c" HEAD 2>/dev/null)" || continue
+ n="$(git rev-list --count "$mb..HEAD" 2>/dev/null)" || continue
+ if [ -z "$best_n" ] || [ "$n" -lt "$best_n" ]; then best="$c"; best_n="$n"; fi
+ done
+ [ -n "$best" ] && echo "$best"
+ return 0
+}
+
+BASE="$(resolve_base)"
+MB=""
+[ -n "$BASE" ] && MB="$(git merge-base "$BASE" HEAD 2>/dev/null || true)"
+
+# packages with a vitest `test` script
+all_test_pkgs() {
+ local d
+ for d in packages/*/; do
+ d="${d%/}"
+ [ -f "$d/package.json" ] || continue
+ node -e 'const s=(require(process.argv[1]).scripts||{}).test||"";process.exit(/\bvitest\b/.test(s)?0:1)' "$ROOT/$d/package.json" 2>/dev/null \
+ && echo "${d#packages/}"
+ done
+}
+
+touched_pkgs() {
+ local f
+ {
+ git diff --name-only "$MB" -- 2>/dev/null
+ git ls-files --others --exclude-standard 2>/dev/null
+ } | while IFS= read -r f; do
+ case "$f" in
+ packages/*/*) f="${f#packages/}"; echo "${f%%/*}" ;;
+ # repo-root scripts + the quarantine lists + this gate are tested in amico-run
+ scripts/*|config/test-quarantine/*|.notturno/*) echo "amico-run" ;;
+ # workspace-wide inputs: every suite
+ package.json|pnpm-lock.yaml|pnpm-workspace.yaml) echo "__ALL__" ;;
+ esac
+ done
+}
+
+ALL_PKGS="$(all_test_pkgs | sort -u | tr '\n' ' ')"
+if [ -z "$MB" ]; then
+ SELECTED="$ALL_PKGS"
+ SEL_WHY="no base resolved — every package"
+else
+ T="$(touched_pkgs | sort -u | tr '\n' ' ')"
+ case " $T " in
+ *" __ALL__ "*) SELECTED="$ALL_PKGS"; SEL_WHY="workspace manifest touched — every package" ;;
+ *)
+ SELECTED=""
+ for p in $T; do case " $ALL_PKGS " in *" $p "*) SELECTED="$SELECTED $p" ;; esac; done
+ SEL_WHY="packages the diff touched"
+ ;;
+ esac
+fi
+SELECTED="$(echo $SELECTED)" # trim / squeeze
+
+say "repo: $ROOT head: $(git rev-parse --short HEAD 2>/dev/null || echo '?')"
+MB_SHORT="(none)"
+[ -n "$MB" ] && MB_SHORT="$(git rev-parse --short "$MB")"
+say "base: ${BASE:-(none)} merge-base: $MB_SHORT"
+say "steps: $STEPS"
+echo "vitest packages: ${SELECTED:-(none)}"
+say "($SEL_WHY)"
+
+# ── root → unprivileged for the vitest steps (#2021) ─────────────────────────
+# The sandbox runs as root; CI's runner does not. Under root, chmod-000/0500
+# permission-denial tests cannot fail, so their assertions break. The decision
+# is made here (and shown by --plan); the user/chown work happens in prepare_drop.
+GATES_EUID="${NOTTURNO_GATES_EUID:-$(id -u 2>/dev/null || echo '?')}"
+DROP_USER="${NOTTURNO_GATES_USER:-notturno-gates}"
+DROP_TOOL=""
+DROP_WHY=""
+if [ "$GATES_EUID" != "0" ]; then
+ DROP_WHY="current user (uid $GATES_EUID, not root)"
+elif [ "${NOTTURNO_GATES_DROP_ROOT:-1}" = "0" ]; then
+ DROP_WHY="root (drop disabled by NOTTURNO_GATES_DROP_ROOT=0)"
+else
+ for t in ${NOTTURNO_GATES_DROP_TOOLS:-setpriv runuser}; do
+ if command -v "$t" >/dev/null 2>&1; then DROP_TOOL="$t"; break; fi
+ done
+ if [ -n "$DROP_TOOL" ]; then DROP_WHY="drop root → non-root user '$DROP_USER' via $DROP_TOOL"
+ else DROP_WHY="root (neither setpriv nor runuser available to drop with)"; fi
+fi
+echo "vitest user: $DROP_WHY"
+[ "$PLAN" -eq 1 ] && exit 0
+
+# ── run ──────────────────────────────────────────────────────────────────────
+PASSED=""
+FAILED=""
+HINTS=""
+T0=$SECONDS
+STEP_DETAIL="" # a step may set this to a short " [..]" suffix for the RESULT line
+step() { # step
+ local name="$1" hint="$2" t=$SECONDS rc
+ shift 2
+ STEP_DETAIL=""
+ say "── $name: $*"
+ "$@"
+ rc=$?
+ if [ "$rc" -eq 0 ]; then
+ PASSED="$PASSED $name"
+ say "── $name: ok ($((SECONDS - t))s)"
+ else
+ FAILED="$FAILED $name$STEP_DETAIL"
+ [ -n "$hint" ] && HINTS="$HINTS $hint;"
+ say "── $name: FAIL exit $rc ($((SECONDS - t))s)${STEP_DETAIL}"
+ fi
+}
+
+# ── CI's environment for the vitest suites (#2007) ───────────────────────────
+SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/notturno-gates.XXXXXX")" || { echo "[notturno-gates] RESULT: RED — mktemp failed"; exit 2; }
+TREE_OWNER="" # set by prepare_drop: who to hand the tree back to
+cleanup() {
+ [ -n "$TREE_OWNER" ] && chown -R "$TREE_OWNER" "$ROOT" 2>/dev/null
+ chmod -R u+rwx "$SCRATCH" 2>/dev/null
+ rm -rf "$SCRATCH"
+}
+trap cleanup EXIT
+CI_TMP="$SCRATCH/tmp"
+mkdir -p "$CI_TMP" "$SCRATCH/runner-temp"
+
+# prepare_drop — make the tree + scratch writable by an unprivileged user and
+# fill DROP (the wrapper argv). On any failure it warns and stays root.
+DROP=()
+DROP_UID=""
+DROP_GID=""
+prepare_drop() {
+ [ -n "$DROP_TOOL" ] || return 0
+ local u="$DROP_USER" owner
+ if ! id -u "$u" >/dev/null 2>&1; then
+ useradd --system --no-create-home --shell /usr/sbin/nologin "$u" >/dev/null 2>&1 \
+ || { u=nobody; say "warn: could not create '$DROP_USER' — falling back to 'nobody'"; }
+ fi
+ DROP_UID="$(id -u "$u" 2>/dev/null)" && DROP_GID="$(id -g "$u" 2>/dev/null)" || DROP_UID=""
+ if [ -z "$DROP_UID" ] || [ "$DROP_UID" = "0" ]; then
+ DROP_UID=""; say "warn: no unprivileged user to drop to — vitest stays root"; return 0
+ fi
+ owner="$(stat -c '%u:%g' "$ROOT" 2>/dev/null || stat -f '%u:%g' "$ROOT" 2>/dev/null)" || owner=""
+ if [ -z "$owner" ] || ! chown -R "$DROP_UID:$DROP_GID" "$ROOT" "$SCRATCH"; then
+ DROP_UID=""; say "warn: could not hand the tree to '$u' — vitest stays root"
+ [ -n "$owner" ] && chown -R "$owner" "$ROOT" 2>/dev/null
+ return 0
+ fi
+ TREE_OWNER="$owner"
+ if [ "$DROP_TOOL" = setpriv ]; then
+ DROP=(setpriv "--reuid=$DROP_UID" "--regid=$DROP_GID" --clear-groups --)
+ else
+ DROP=(runuser -u "$u" --)
+ fi
+ say "vitest steps run as '$u' (uid $DROP_UID), tree handed over until exit"
+}
+
+# run a command the way CI's `fast` job runs a test step
+ci_env() {
+ if [ "${NOTTURNO_GATES_HERMETIC:-1}" = "0" ]; then ${DROP[@]+"${DROP[@]}"} "$@"; return; fi
+ local home
+ home="$(mktemp -d "$SCRATCH/home.XXXXXX")" || return 2 # fresh + empty per suite
+ [ -n "$DROP_UID" ] && chown "$DROP_UID:$DROP_GID" "$home"
+ local keep=() v
+ for v in PATH LANG LC_ALL USER LOGNAME SHELL; do
+ [ -n "${!v:-}" ] && keep+=("$v=${!v}")
+ done
+ ${DROP[@]+"${DROP[@]}"} env -i "${keep[@]}" \
+ HOME="$home" TMPDIR="$CI_TMP" RUNNER_TEMP="$SCRATCH/runner-temp" TZ=UTC \
+ CI=true GITHUB_ACTIONS=true ${BASE:+GITHUB_BASE_REF="${BASE#origin/}"} \
+ "$@"
+}
+
+vitest_bin() { # the package's own vitest bin (no pnpm/corepack under the scratch HOME)
+ if [ -x "packages/$1/node_modules/.bin/vitest" ]; then echo "$ROOT/packages/$1/node_modules/.bin/vitest"; else echo ""; fi
+}
+
+# failed_files [quarantine-gate-log] → "test/a.test.ts, test/b.test.ts, +N more" (first 5)
+failed_files() {
+ node - "$@" <<'NODE' 2>/dev/null
+const fs = require("fs"), path = require("path");
+const [json, gateLog] = process.argv.slice(2);
+const files = [];
+const add = (f) => { if (f && !files.includes(f)) files.push(f); };
+if (gateLog && fs.existsSync(gateLog)) {
+ // quarantine-gate violations: "... FAIL ::::: ..."
+ for (const m of fs.readFileSync(gateLog, "utf8").matchAll(/ FAIL [\w-]+ [^:\s]+::([^:]+)::/g)) add(m[1]);
+}
+if (!files.length && json && fs.existsSync(json)) {
+ let r; try { r = JSON.parse(fs.readFileSync(json, "utf8")); } catch { r = null; }
+ for (const t of (r && r.testResults) || []) {
+ const bad = t.status === "failed" || (t.assertionResults || []).some((a) => a.status === "failed");
+ if (bad) add(path.relative(process.env.PKG_DIR || process.cwd(), t.name));
+ }
+}
+if (files.length) {
+ const head = files.slice(0, 5);
+ if (files.length > 5) head.push(`+${files.length - 5} more`);
+ process.stdout.write(head.join(", "));
+}
+NODE
+}
+
+set_detail() { # set_detail [gate-log]
+ local f
+ f="$(PKG_DIR="$ROOT/packages/$1" failed_files "$2" "${3:-}")"
+ [ -n "$f" ] && STEP_DETAIL=" [$f]"
+ return 0
+}
+
+vitest_args() { # common CI flags + exclusions + a JSON report
+ local g
+ VARGS=(run --passWithNoTests --exclude '**/slow/**')
+ for g in $ENV_DEPENDENT_EXCLUDES; do VARGS+=(--exclude "$g"); done
+ VARGS+=(--reporter=default --reporter=json "--outputFile.json=$1")
+}
+
+run_vitest() { # run_vitest
+ local bin
+ bin="$(vitest_bin "$1")"
+ vitest_args "$2"
+ if [ -n "$bin" ]; then
+ (cd "packages/$1" && ci_env "$bin" "${VARGS[@]}")
+ else
+ (cd "packages/$1" && ci_env pnpm exec vitest "${VARGS[@]}")
+ fi
+}
+
+run_amico_run_suite() {
+ local out="$SCRATCH/vitest-amico-run.json" glog="$SCRATCH/quarantine-amico-run.log" vexit qexit
+ run_vitest amico-run "$out"
+ vexit=$?
+ (cd packages/amico-run && node ../../scripts/test_quarantine_gate.mjs --runner-exit "$vexit" "$out") 2>&1 | tee "$glog"
+ qexit=${PIPESTATUS[0]}
+ [ "$qexit" -ne 0 ] && set_detail amico-run "$out" "$glog"
+ return "$qexit"
+}
+
+run_pkg_suite() {
+ local p="$1" out="$SCRATCH/vitest-$1.json" rc
+ run_vitest "$p" "$out"
+ rc=$?
+ [ "$rc" -ne 0 ] && set_detail "$p" "$out"
+ return "$rc"
+}
+
+if want vitest && [ -n "$SELECTED" ]; then
+ NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo '?')"
+ say "vitest env: $([ "${NOTTURNO_GATES_HERMETIC:-1}" = "0" ] && echo 'AMBIENT (NOTTURNO_GATES_HERMETIC=0)' || echo "CI mirror — env -i, CI=true GITHUB_ACTIONS=true TZ=UTC, scratch HOME under $SCRATCH${BASE:+, GITHUB_BASE_REF=${BASE#origin/}}"); node $(node --version 2>/dev/null || echo '?')"
+ [ "$NODE_MAJOR" != "20" ] && say "warn: node major $NODE_MAJOR != CI's 20 (actions/setup-node node-version: 20) — a red that CI would not show may be a node-version difference"
+ [ "$GATES_EUID" = "0" ] && [ -z "$DROP_TOOL" ] && say "warn: running vitest as root — CI's runner is unprivileged; permission tests (chmod 000/0500 dirs) cannot fail for root"
+ MISSING=""
+ for t in ${NOTTURNO_GATES_CI_TOOLS:-zip rsync shasum}; do command -v "$t" >/dev/null 2>&1 || MISSING="$MISSING $t"; done
+ [ -n "$MISSING" ] && say "warn: tools CI's runner has are missing here:$MISSING — suites that spawn them fail ENOENT / exit 127 (sandbox image gap, not a code bug)"
+fi
+
+if want drift; then
+ step drift "overlay edit without a manifest refresh? run 'node packages/app-bundle/scripts/refresh_manifest.mjs' then drift_gate, and commit manifest.json" \
+ node packages/app-bundle/scripts/drift_gate.mjs
+fi
+if want typecheck; then
+ step typecheck "" pnpm -r run typecheck
+fi
+if want vitest && [ -n "$SELECTED" ]; then
+ want build && step build "" pnpm -r run build
+ prepare_drop # after the build (root-owned dist/), before the suites
+ for p in $SELECTED; do
+ if [ "$p" = "amico-run" ]; then
+ step "vitest:$p" "" run_amico_run_suite
+ else
+ step "vitest:$p" "" run_pkg_suite "$p"
+ fi
+ done
+fi
+
+DUR=$((SECONDS - T0))
+if [ -n "$FAILED" ]; then
+ echo "[notturno-gates] RESULT: RED (${DUR}s) — failed:${FAILED}; passed:${PASSED:- none}${HINTS:+ — fix:$HINTS}"
+ exit 1
+fi
+echo "[notturno-gates] RESULT: GREEN (${DUR}s) — passed:${PASSED:- none} (base ${BASE:-none})"
+exit 0
diff --git a/AGENTS.md b/AGENTS.md
index 1a5a7b796..8d0953eff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,6 +68,16 @@ macOS note: the vendored binary is unsigned — if Gatekeeper blocks it:
| Julia solve E2E | `AMICO_TEST_JULIA_PROJECT=$HOME/.amico/julia pnpm --filter amicode run test:slow` | template vet passes, F > 0.999 |
| Live interview E2E | `cd packages/extension && AMICODE_E2E_LIVE=1 npx vitest run test/slow/interview_e2e.test.ts` | tiers A/B/C pass (C needs a provider; flaky on free tier) |
| Full chain (opt-in, ~3 min) | add `AMICODE_E2E_FULLCHAIN=1` | tier D: interview → real solve → F > 0.99 |
+| Pre-push gates (night runner) | `./.notturno/gates` | `RESULT: GREEN` — drift gate + `pnpm -r run typecheck` + vitest of the packages the diff touched (vs the merge-base with `NOTTURNO_BASE`, else the nearest `origin/lane/*`/`origin/main`) |
+
+**Overlay rule (CI's app-bundle drift gate):** any edit under `packages/app-bundle/overlay/**`
+(add, change, delete, rename) must be followed by
+`node packages/app-bundle/scripts/refresh_manifest.mjs` and then
+`node packages/app-bundle/scripts/drift_gate.mjs` (expect `PASS`) **before you commit**;
+commit the refreshed `packages/app-bundle/manifest.json` with the overlay change.
+`refresh_manifest` needs the materialized upstream tree
+(`pnpm --filter @amicode/app-bundle run materialize` once). An overlay edit without the
+refresh reds CI (2 of night batch 1's red PRs, #1980) and reds `./.notturno/gates`.
## Development facts you need
@@ -177,6 +187,12 @@ passes, **promote** it → clean `v0.0.3` → Marketplace. The first promotion o
bump (alphas never hit the Marketplace, so the version is still free); re-promoting an
already-published base does.
+**After a CLI-changing release, refresh the pinned CLI on every machine** (#1949): the `amico`
+shims run the pinned dist (`~/.amico/server/cli/`), not the checkout, so a CLI change has not
+shipped on a machine until its pin is refreshed. From that machine's freshly built checkout:
+`bash ops/install-cli-pin.sh --dist packages/amico-run/dist` (then `amico doctor` → `pinned_cli`
+reports the new sha). See `ops/README.md` § The pinned CLI root.
+
## Known sharp edges
- `test:slow` without `AMICO_TEST_JULIA_PROJECT` silently skips the Julia gates.
diff --git a/CONTEXT.md b/CONTEXT.md
index a5a14099e..8c006626b 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -301,6 +301,18 @@ _Avoid_: Explorer (VS Code's native file explorer is separate), Panel (the in-ap
The multi-document file workspace in the side panel. Holds zero or more files as inner tabs, each rendering its content (markdown rendered with a toggle to edit; text/code files in a CodeMirror editor; images and PDFs inline). Files arrive via Sidebar single-click or a Chat file pill and accumulate as tabs — each closeable and drag-reorderable; opening an already-open file focuses its existing tab rather than duplicating it. A breadcrumb bar under each pane's tab strip shows the active file's project-relative path with interactive sibling navigation. Supports recursive split panes via edge-drop: dragging a tab toward a pane's edge divides the view, and each resulting pane keeps its own tab bar, breadcrumb, zoom, and preview/edit toggle. Empty panes auto-collapse; a minimum pane dimension is enforced so splits can't shrink below a usable size. Opens and activates automatically when the first file is selected; shows a placeholder when no file is open. For committed editing, double-click the Sidebar entry to open a native VS Code tab.
_Avoid_: Editor (Preview is a multi-document viewer, not a primary editor — committed editing belongs in a native VS Code tab), File browser (the Sidebar is still the primary project-wide file tree; the breadcrumb is a contextual sibling-navigation aid scoped to the open file, not a second tree)
+**Paper Mode**:
+A first-class, opt-in session surface for *committed* LaTeX co-editing: the `.tex` editor is primary, Chat docks beneath it, and the compiled PDF sits to the right. Distinct from Preview (which stays a viewer) and from the Work Column layout — Paper Mode is its own layout, not a resize of an existing one. The live editor Draft is the single source of truth; the agent reads it fresh each turn; agent edits arrive as reviewable Pending Hunks rather than direct disk writes; and compilation is manual. Does not depend on the paused wide-mode work.
+_Avoid_: Write mode (superseded name), Overleaf mode, wide mode (a separate, paused feature)
+
+**Draft** (Paper Mode):
+The live in-editor content of a `.tex` file, versioned by a monotonic Content Revision. In Paper Mode it is the single source of truth: the human's typing and accepted agent edits both land here, saves flush it to disk, and the agent always reads it at its current revision. Supersedes disk and any cached copy as the authority for "current content."
+_Avoid_: Buffer (implementation detail), file contents (ambiguous with disk)
+
+**Pending Hunk**:
+A proposed agent edit shown as an inline, reviewable diff decoration in the Paper Mode editor, not yet written to the Draft or disk. Accepted (`✓`) hunks apply to the Draft; rejected (`✗`) hunks vanish. A Pending Hunk that overlaps concurrent human edits becomes a Conflict Hunk requiring explicit resolution — nothing lands unchecked. Only accepted hunks emit mutation-ledger receipts and enter the compiled source.
+_Avoid_: Suggestion, agent write (in Paper Mode the agent proposes, it does not write disk), pending change
+
### Developer tooling
**Rebuild (local)**:
diff --git a/docs/adr/0037-armonia-trust-boundaries.md b/docs/adr/0037-armonia-trust-boundaries.md
new file mode 100644
index 000000000..23a25293c
--- /dev/null
+++ b/docs/adr/0037-armonia-trust-boundaries.md
@@ -0,0 +1,107 @@
+---
+status: proposed
+date: 2026-10-27
+deciders: Aaron Trowbridge
+tags: [security, fleet, armonia, tailscale, lab-deployment]
+supersedes: []
+related: ["#1872", "#1874", "#1879", "#1887", "#1956", "#1957", "spec-20261026-101500-rd-system-v2-1 (A15, A16)", "amicissimo ADR-0007 (C2 signed artifacts)"]
+---
+
+# ADR 0037: Armonia trust boundaries (Tailscale transport, sovereign clients, sandboxed agents)
+
+## Context
+
+Armonia deployments put a shared hub next to personal machines. The hub is a lab computer or a Modal container. It runs unattended agents and sits in front of instruments. Students, PIs, and outside collaborators attach laptops to it. The lab wants that system to be secure by design. The people using it want install to be one command and one click.
+
+A capability audit of the hub-client posture (2026-10-27) found:
+
+**Already sound:**
+- In hub-client posture the laptop runs no server and opens no port.
+- The tunnel is forward-only and has no `-R`.
+- The agent tool server runs on the hub.
+- Link resolution previews only vault roots.
+- Widget iframes are sandboxed.
+
+**Unsafe:**
+- **Hub-served code runs on the laptop.** The chat panel's JavaScript is served by the hub from a directory the agents' OS user can write. It runs inside the laptop's VS Code and can send about 40 kinds of bridge message to the extension. Some of those:
+ - run processes without consent: `dev-tools-rebuild` and `dev-tools-build-vsix` run git and `pnpm install` in any path;
+ - read the clipboard silently;
+ - change settings;
+ - push workspace paths;
+ - watch arbitrary files;
+ - send typed credentials to the hub.
+- **The frontdoor listened unauthenticated on `0.0.0.0`.** It was reachable from the LAN as well as the tailnet. Fixed by #1957 (loopback by default).
+- **Remote access ran over hand-built SSH tunnels on per-machine ports.** Settings Sync carried one machine's port to another and hung its boot (#1956).
+- **Agents run as the same OS user as everything else on the hub:** the UI it serves, the gates, the charter.
+
+## Decision
+
+### 1. Tailscale is the transport and the identity bootstrap
+- The frontdoor listens on loopback only. It is published to the tailnet with `tailscale serve`, giving HTTPS at `https://..ts.net` with Tailscale-issued certificates.
+- Nothing is exposed on a LAN or to the public internet. SSH tunnels survive only as a fallback.
+- The frontdoor maps the verified `Tailscale-User-Login` / `Tailscale-User-Name` headers that `serve` injects to #1874's person principal. These headers are trusted only on the loopback hop from `tailscaled`.
+- The principal model stays transport-independent: an OIDC login can produce the same principal later.
+- **Tailnet policy encodes the boundary**, shipped as a reviewed snippet:
+ - `tag:armonia-hub` accepts traffic from lab members;
+ - `tag:armonia-site` and `tag:lab-instrument` accept traffic only from the hub;
+ - **no rule permits connections to personal devices.**
+- Execution sites (lab PCs, Modal sandboxes, cluster login nodes) join with short-lived, pre-approved tagged auth keys. They are identities by tag, never by person.
+- **Tailscale is transport; Armonia is authorization.** Being reachable grants nothing. Membership, campaigns, charters, and Strumento decide.
+- Labs that cannot use the hosted control plane run **Headscale** (the Enterprise profile), which gives the same model. Where neither is permitted, the fallback is plain WireGuard managed by `armonia init`.
+
+### 2. The client is sovereign: the hub sends data, never code
+- In hub-client posture, the panel loads its UI from the **installed, signed extension**, not from the hub.
+- If a deployment opts into hub-served UI, the client loads it only when its bundle signature verifies against the pinned root set (the amicissimo C2 channel). An agent editing the hub's dist then changes nothing on any laptop.
+- **The bridge is a capability table.** Every message kind is classed per posture:
+ - **view:** no side effects;
+ - **consented:** a native VS Code prompt naming the hub and the action, optionally remembered per hub;
+ - **forbidden in hub-client posture:** `dev-tools-*`, `data-storage-update`, `skill-providers-autodiscover`/`add`, arbitrary `watch-files`.
+- **Clipboard** reads happen only from a real user paste in the webview. There is no read-on-request path.
+- **Share nothing by default.** Workspace paths are not pushed to the hub. Opening, watching, and previewing local files is limited to folders the user has explicitly shared with that hub, listed and revocable in a "Shared with " view.
+- **Personal credentials stay on the laptop.** Hub-side features use deployment or service credentials. A form that would store a user's key on a hub says so before submit.
+- **The laptop keeps its own audit log** of every request the hub made and how it was answered.
+
+### 3. Agents are sandboxed apart from what users run
+- On the hub, agents run as a separate OS user (or in a container, gVisor, or Modal Sandbox per #1879).
+- That user cannot write:
+ - the served UI;
+ - the gate scripts;
+ - the charter;
+ - the deployment config;
+ - other principals' worktrees.
+- Credentials reachable by agents are short-lived and bound to a principal (A15 mint records; Bedrock via identity tokens per #1887, not keys).
+
+### 4. Install is one command and one click
+- **Hub:** `armonia init --lab ""`. It runs these steps:
+ 1. host checks;
+ 2. creates the agent user and sandbox runtime;
+ 3. joins the tailnet and tags the machine;
+ 4. emits the ACL snippet (or applies it through the API);
+ 5. mints the lab starter vault and signs the conservative charter;
+ 6. installs services that update from the signed channel;
+ 7. prints a join link and QR code.
+- **Laptop:** install the extension, then open the join link.
+ - The Tailscale login, via the institution's SSO, admits the device.
+ - MagicDNS locates the hub.
+ - A "share folders?" screen opens with nothing selected.
+ - No ports, keys, or settings files are involved.
+- **Instrument or site machine:** `armonia site join --kind lab` with a one-time tagged key. Outbound only.
+- **`armonia doctor`** reports tailnet membership, identity, hub reachability, version skew, signatures, and charter state. Each result is a named outcome with its remedy.
+
+## Consequences
+
+- Port drift, tunnel daemons, and the `fleet.repair` port migration stop being necessary once clients use the MagicDNS URL. #1956's machine-scoped settings remain, as defense in depth.
+- A compromised hub, or an agent on it, can no longer run code, read the clipboard, or browse files on a laptop. The worst case reduces to what the user shared or explicitly consented to.
+- Tailscale becomes a deployment dependency for the Lab and Harmoniqs profiles. The Open profile (local only) is unaffected. Headscale or WireGuard cover labs that cannot use it.
+- The capability table and signed UI bundle are new extension work. Hub-served UI stops being the default update path for laptops: UI updates ride the signed extension or a signed bundle.
+
+## Slices
+
+1. **#1957** frontdoor loopback bind (done and live).
+2. `tailscale serve` publishing plus identity-header principal mapping in the frontdoor. Clients use the MagicDNS URL. The tunnel becomes a fallback.
+3. The bridge capability table: forbid or consent per posture, clipboard on paste only, `open-file`/`watch-files` scoped to shared folders, no automatic workspace push.
+4. The panel loads UI from the extension in hub-client posture; signed hub bundle opt-in.
+5. Separate agent OS user on the hub with read-only served UI, gates, and charter (with #1879).
+6. Tailnet ACL/grants snippet plus tagged site auth keys; `armonia site join`.
+7. `armonia init` and `armonia doctor`.
+8. The "Shared with " view and the laptop-side audit log.
diff --git a/docs/adr/0038-paper-mode-snapshot-base-3way-merge.md b/docs/adr/0038-paper-mode-snapshot-base-3way-merge.md
new file mode 100644
index 000000000..2c462a3bb
--- /dev/null
+++ b/docs/adr/0038-paper-mode-snapshot-base-3way-merge.md
@@ -0,0 +1,27 @@
+# ADR 0038 — Paper Mode: live-draft source of truth and snapshot-base 3-way merge for AI/human co-editing
+
+Status: proposed (2026-09-28)
+
+Tracking: harmoniqs/amicode#1620 · Glossary: `CONTEXT.md` (Paper Mode, Draft, Pending Hunk)
+
+Paper Mode is a new opt-in session surface for *committed* LaTeX writing (editor primary, Chat docked beneath, PDF right; distinct from Preview, which stays a viewer, and independent of the paused wide-mode work). It exists because Amicode has no safe surface for AI and human to co-edit the same `.tex`: today the agent reads a stale disk/cache snapshot (edits it "saw" go missing from its output), agent writes bypass the editor buffer (the human must reopen the file), concurrent edits clobber silently, and there is no way to review an agent change before it lands or to control when compilation runs.
+
+The decision is fourfold, and each part is hard to reverse because tooling, UI, and the mutation boundary all bind to it:
+
+1. **The live editor Draft is the single source of truth**, versioned by a monotonic Content Revision (extending the content-revision rule ADR-0013 already mandates). In Paper Mode the agent's Read/Edit tools read the Draft *at its current revision every turn* — never disk, never a cache. "The agent always sees my edits" becomes true by construction and checkable, because the revision it read is stamped. This supersedes the coarse revert-based fix in #1422, which only reverts a *clean* buffer and blocks while the human is typing — insufficient for real co-editing.
+
+2. **Agent edits propose to a review layer; they do not write disk.** An agent Edit/Write in Paper Mode produces Pending Hunks — inline diff decorations in the editor — leaving the Draft and disk unchanged until the human accepts. The human's own typing writes the Draft as normal and is always authoritative. Un-accepted agent text is therefore never in the file, which is what makes "the AI never interferes," "human always wins," and "nothing compiles behind my back" all fall out of one mechanism.
+
+3. **Concurrent edits reconcile via a snapshot-base 3-way merge.** The Content Revision the agent read at edit-start is captured as the merge base. At apply time we 3-way merge (base → human-edited Draft, base → agent hunks): non-overlapping regions land as Pending Hunks; regions both parties touched become **Conflict Hunks** the human must resolve. Conflicts are **never auto-resolved** — the human explicitly wanted to be able to check whether their text was removed. CM6's `@codemirror/merge` (already a dependency, already used in `editable-diff-view.tsx`) supplies the merge/decoration primitive.
+
+4. **Compilation is manual, on the accepted Draft.** It fires only on an explicit Compile action and compiles the Draft as it stands — Pending (un-accepted) Hunks are excluded because they are not yet in the file. Accepted hunks emit ADR-0017 mutation-ledger receipts (appearing in Files Changed with real provenance); pending and rejected hunks emit nothing.
+
+## Considered options
+
+- **Agent-writes-disk with a revert-style overlay (today's #1422 model), rejected.** Reachable from current code, but the agent's text *is* in the file before the human approves, so "human always wins," "review before it lands," and "compile only on approve" all become muddy special cases rather than consequences of the model. The dirty-buffer guard also silently blocks agent edits whenever the human is typing, which is the opposite of collaboration.
+- **CRDT live merge (Yjs), rejected for now.** A shared mergeable document gives Google-Docs-style seamlessness, but it is a heavy, hard-to-reverse architectural commitment, and per-hunk human review sits *on top* of it anyway. The snapshot-base 3-way merge delivers the reviewed-collaboration requirement with far less machinery; revisit only if real-time multi-human co-presence becomes a requirement.
+- **Promote Preview into a primary editor, rejected.** Reverses ADR-0013 and the glossary's deliberate "Preview = viewer" boundary and tangles viewer and editor responsibilities in one component. Paper Mode is a separate bounded surface instead.
+
+## Consequences
+
+The agent's Edit/Write tools gain a Paper-Mode-specific behavior (propose to the review layer instead of writing disk), which must fail closed — an agent edit that cannot be expressed as a Pending Hunk is surfaced, not silently written. The Draft/revision authority and the review/merge UI are new machinery, but both build on primitives that already exist (ADR-0013 content revisions, ADR-0017 receipts, `@codemirror/merge`). Paper Mode deliberately does **not** depend on the paused wide-mode work (#1434) and is built as its own layout. LaTeX language intelligence (#1513), SyncTeX (#1432/#1514), an outline navigator, and in-editor search (#1430) are out of scope — they dock into Paper Mode later as independent slices. Revisit this decision only if Amicode adopts real-time multi-human collaboration, at which point option 2 (CRDT) is the successor.
diff --git a/docs/releases/0.3.10.md b/docs/releases/0.3.10.md
new file mode 100644
index 000000000..d2d190785
--- /dev/null
+++ b/docs/releases/0.3.10.md
@@ -0,0 +1,25 @@
+---
+version: 0.3.10
+date: 2026-10-27
+highlights:
+ - title: Sticky widgets
+ body: >-
+ Opt a widget into "sticky" and it docks as a compact strip when you
+ scroll it out of view, so a live readout stays in sight while the
+ conversation moves on.
+ - title: Step-limited subagents report what they finished
+ body: >-
+ A subagent that runs out of steps now hands back an EXHAUSTED receipt —
+ what it finished and what is left — instead of stopping silently.
+ - title: Specs that file their own issues
+ body: >-
+ "amico spec slices" turns a spec's plan rows into linked GitHub issues,
+ and "amico census" gives a quick read of open work with named gaps and a
+ 72-hour stall tripwire.
+ - title: Safer hub deploys
+ body: >-
+ The hub now deploys a lane build with an atomic switch, pinned
+ provenance, and one-step rollback.
+links:
+ notes: https://github.com/harmoniqs/amicode/releases/tag/v0.3.10
+---
diff --git a/docs/releases/0.4.0.md b/docs/releases/0.4.0.md
new file mode 100644
index 000000000..dc7f7b6d1
--- /dev/null
+++ b/docs/releases/0.4.0.md
@@ -0,0 +1,38 @@
+---
+version: 0.4.0
+date: 2026-10-29
+highlights:
+ - title: A model picker that knows what you can actually reach
+ body: >-
+ The picker now probes your connected providers and hides models your
+ account can't use — "Show unavailable (N)" brings them back when you
+ want to look. Star your favorites, see the recommended model for each
+ role, and compare price and latency right in the list. If you pick a
+ model that has gone dead, you get a warning with a one-click switch to a
+ working one.
+ - title: The night engine opens PRs that already passed CI's gates
+ body: >-
+ Overnight runs now check their work against the same cheap gates CI
+ runs (drift, typecheck, the tests the change touched) before opening a
+ pull request, so the morning queue is green PRs, not red ones.
+ - title: The hub frontdoor listens on loopback only
+ body: >-
+ The hub's frontdoor now binds to 127.0.0.1 and is reached through the
+ fleet tunnel, never the open network. ADR 0037 writes down the trust
+ boundaries behind it.
+ - title: Skills for every engine
+ body: >-
+ The amicode skill catalog now reaches every agent engine you run, not
+ just the default one — and when a skill can't load, it says so instead
+ of quietly going missing.
+ - title: Question notifications, fixed
+ body: >-
+ You no longer get a question or permission notification for the session
+ you are already looking at; the ones for your other sessions still
+ arrive.
+links:
+ notes: https://github.com/harmoniqs/amicode/releases/tag/v0.4.0
+---
+
+This page appears once after each update. Reopen it any time from the command
+palette ("Amicode: What's New") or the status popover.
diff --git a/ops/README.md b/ops/README.md
index 5600fa0d7..1f919b048 100644
--- a/ops/README.md
+++ b/ops/README.md
@@ -92,6 +92,10 @@ bash ops/install-cli-pin.sh --dist packages/amico-run/dist
# sidecars (shasum format) — the papers-digest convention
```
+**Release checklist step** (#1949): a CLI change ships only once the pin is refreshed —
+run the two lines above on **every machine** after any release that changes
+`packages/amico-run`. Until then that machine's `amico` keeps running the old pinned dist.
+
`amico doctor` reports the pin as the `pinned_cli` record: the root, the bundle
count, the `amico.js` sha, and the sidecars' freshness (a mismatched or missing
sidecar is an integrity error — refresh the pin; an absent root is a warning, the
diff --git a/ops/hub/README.md b/ops/hub/README.md
index 89bee8a45..6bb381d28 100644
--- a/ops/hub/README.md
+++ b/ops/hub/README.md
@@ -13,6 +13,7 @@ against a file that lived nowhere. This import fixes that.
| `amicode-server.sh` | `~/.amico/server/amicode-server.sh` | systemd wrapper: binary resolution, skill staging, env, spawns the service runner (M3 cutover, #955) |
| `engine-gateway-secret.sh` | `~/.amico/server/engine-gateway-secret.sh` | #1760 P1: generates/serves the one-door shared secret (`~/.amico/server/engine-gateway-secret.env`, mode 600) |
| `deploy-dist.sh` | run in place from a lane worktree (not copied) | #1906 A13b: the app-dist deploy — lane-tip/dirty refusals, release dirs, atomic `dist-app` symlink flip, `dist-deploys.jsonl` provenance, keep-N prune, `--rollback` |
+| `deploy-gate.sh` | run in place from a checkout (not copied) | #1725: the pre-restart battery. Its staged-binary smoke stops the server with TERM → ≤10s bounded wait → KILL; a server that ignores TERM is a named smoke FAIL (#1944). Gate: `python3 ops/hub/test_deploy_gate.py` on the hub from `/tmp` (drives the real smoke via `AMICODE_GATE_SMOKE_ONLY=1` against fake servers, temp `HOME`) |
| `fleet-watchdog/rss-watchdog.sh` | `~/.amico/server/fleet-watchdog/rss-watchdog.sh` | 5-min RSS trajectory + corroboration probe + wedge capture + restart authority |
| `fleet-watchdog/cdp-stack.mjs` | `~/.amico/server/fleet-watchdog/cdp-stack.mjs` | CDP all-threads JS stack capture for wedge postmortems (connects to the engine's BUN_INSPECT port) |
diff --git a/ops/hub/__pycache__/hub-frontdoor.cpython-310.pyc b/ops/hub/__pycache__/hub-frontdoor.cpython-310.pyc
deleted file mode 100644
index 257508321..000000000
Binary files a/ops/hub/__pycache__/hub-frontdoor.cpython-310.pyc and /dev/null differ
diff --git a/ops/hub/__pycache__/hub-frontdoor.cpython-312.pyc b/ops/hub/__pycache__/hub-frontdoor.cpython-312.pyc
deleted file mode 100644
index 405c32351..000000000
Binary files a/ops/hub/__pycache__/hub-frontdoor.cpython-312.pyc and /dev/null differ
diff --git a/ops/hub/__pycache__/hub-frontdoor.cpython-313.pyc b/ops/hub/__pycache__/hub-frontdoor.cpython-313.pyc
deleted file mode 100644
index 9f6ac18de..000000000
Binary files a/ops/hub/__pycache__/hub-frontdoor.cpython-313.pyc and /dev/null differ
diff --git a/ops/hub/__pycache__/jev_placement.cpython-310.pyc b/ops/hub/__pycache__/jev_placement.cpython-310.pyc
deleted file mode 100644
index aabdac10f..000000000
Binary files a/ops/hub/__pycache__/jev_placement.cpython-310.pyc and /dev/null differ
diff --git a/ops/hub/__pycache__/jev_placement.cpython-313.pyc b/ops/hub/__pycache__/jev_placement.cpython-313.pyc
deleted file mode 100644
index 3004815a6..000000000
Binary files a/ops/hub/__pycache__/jev_placement.cpython-313.pyc and /dev/null differ
diff --git a/ops/hub/deploy-gate.sh b/ops/hub/deploy-gate.sh
index 3efbb91e9..edcb555a4 100755
--- a/ops/hub/deploy-gate.sh
+++ b/ops/hub/deploy-gate.sh
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
+# shellcheck disable=SC2317 # smoke/smoke_stop/panel_probe run indirectly via run_check
# deploy-gate.sh — the pre-rebuild battery. NO hub restart without this green.
#
# amicode #1725 (2026-10-07): every same-day production break was a SEAM bug —
@@ -21,6 +22,16 @@
# standing constraint is an ACCEPTANCE CRITERION: no surface ships in the
# user's panel without live data. Dead-widgets-zero or the dist does not ship.
# AMICODE_GATE_QUICK=1 skips the engine test lane (typecheck still runs).
+# AMICODE_GATE_SMOKE_ONLY=1 (needs --staged-binary) runs ONLY the staged boot
+# smoke — no seam suites, no engine lanes, and NO gate approval recorded. It
+# exists so ops/hub/test_deploy_gate.py can drive the real smoke() against
+# fake servers (#1949); a smoke-only run is never a deploy verdict.
+#
+# THE SMOKE'S SHUTDOWN IS BOUNDED (#1944, #1949): the staged server gets TERM,
+# then up to AMICODE_GATE_SMOKE_TERM_WAIT seconds (default 10) to exit, then
+# KILL. A server that outlives TERM is a NAMED smoke failure — a binary that
+# cannot shut down cleanly would wedge every hub restart — and the gate itself
+# can never hang on it again.
#
# Exit 0 = deployable. Anything else = STOP — no stage, no swap, no restart.
set -u
@@ -32,6 +43,7 @@ PANEL_GATE="${AMICODE_PANEL_GATE:-0}"
PANEL_HOST="${AMICODE_PANEL_HOST:-macbook}"
PANEL_HARNESS="${AMICODE_PANEL_HARNESS:-/tmp/opencode/panel-probe}"
QUICK="${AMICODE_GATE_QUICK:-0}"
+SMOKE_ONLY="${AMICODE_GATE_SMOKE_ONLY:-0}"
while [ $# -gt 0 ]; do
case "$1" in
--scope) SCOPE="${2:?}"; shift 2 ;;
@@ -43,15 +55,9 @@ done
FAILED=0
-# the watchdog's source-of-truth branch differs from the frontdoor's — resolve
-# wherever this run can see it (the repo checkout, else the hub's deployed copy)
-WATCHDOG=""
-for cand in "$HERE/../fleet-watchdog/rss-watchdog.sh" "$HOME/.amico/server/fleet-watchdog/rss-watchdog.sh"; do
- if [ -f "$cand" ]; then WATCHDOG="$cand"; break; fi
-done
-if [ -z "$WATCHDOG" ]; then
- echo "GATE CANNOT RUN: rss-watchdog.sh not found (repo or hub deploy path)"
- exit 2
+if [ "$SMOKE_ONLY" = "1" ]; then
+ [ -n "$STAGED_BIN" ] || { echo "AMICODE_GATE_SMOKE_ONLY=1 needs --staged-binary "; exit 2; }
+ SCOPE="smoke-only"
fi
run_check() {
@@ -67,28 +73,42 @@ run_check() {
echo
}
-# ── 1. syntax: everything that would run must parse first ────────────────────
-run_check "frontdoor py-compile" python3 -m py_compile "$HERE/hub-frontdoor.py" "$HERE/jev_placement.py"
-run_check "watchdog bash -n" bash -n "$WATCHDOG"
+if [ "$SMOKE_ONLY" != "1" ]; then
+ # the watchdog's source-of-truth branch differs from the frontdoor's — resolve
+ # wherever this run can see it (the repo checkout, else the hub's deployed copy)
+ WATCHDOG=""
+ for cand in "$HERE/../fleet-watchdog/rss-watchdog.sh" "$HOME/.amico/server/fleet-watchdog/rss-watchdog.sh"; do
+ if [ -f "$cand" ]; then WATCHDOG="$cand"; break; fi
+ done
+ if [ -z "$WATCHDOG" ]; then
+ echo "GATE CANNOT RUN: rss-watchdog.sh not found (repo or hub deploy path)"
+ exit 2
+ fi
-# ── 2. derived-config distinctness (the marker-race class: two watchdog
-# instances must never share a probe port, pgrep pattern, or log path —
-# 2026-10-07's shared-marker bug left a wedged shard un-restarted for 40 min) ─
-run_check "watchdog shard-env distinctness" bash -c '
-set -u
-wd="$1"; tmp=$(mktemp); trap "rm -f \"$tmp\"" EXIT
-for i in 1 2 3; do AMICODE_SHARD_INDEX=$i bash "$wd" --print-shard-env >> "$tmp"; done
-fail=0
-for field in service health_port pattern log inspect_port; do
- n=$(( $(grep "^$field=" "$tmp" | cut -d= -f2- | sort -u | wc -l | tr -d " ") ))
- [ "$n" = "3" ] || { echo " $field not distinct across shards ($n distinct values)"; fail=1; }
-done
-exit $fail
-' gate "$WATCHDOG"
+ # ── 1. syntax: everything that would run must parse first ────────────────────
+ run_check "frontdoor py-compile" python3 -m py_compile "$HERE/hub-frontdoor.py" "$HERE/jev_placement.py"
+ run_check "watchdog bash -n" bash -n "$WATCHDOG"
-# ── 3. the seam suites (fake shards + the real frontdoor as a subprocess) ─────
-run_check "frontdoor integration suite (the seams)" python3 "$HERE/test_frontdoor.py"
-run_check "jev placement unit suite" python3 "$HERE/test_jev_placement.py"
+ # ── 2. derived-config distinctness (the marker-race class: two watchdog
+ # instances must never share a probe port, pgrep pattern, or log path —
+ # 2026-10-07's shared-marker bug left a wedged shard un-restarted for 40 min) ─
+ # shellcheck disable=SC2016 # the single quotes are the point: an inline script
+ run_check "watchdog shard-env distinctness" bash -c '
+ set -u
+ wd="$1"; tmp=$(mktemp); trap "rm -f \"$tmp\"" EXIT
+ for i in 1 2 3; do AMICODE_SHARD_INDEX=$i bash "$wd" --print-shard-env >> "$tmp"; done
+ fail=0
+ for field in service health_port pattern log inspect_port; do
+ n=$(( $(grep "^$field=" "$tmp" | cut -d= -f2- | sort -u | wc -l | tr -d " ") ))
+ [ "$n" = "3" ] || { echo " $field not distinct across shards ($n distinct values)"; fail=1; }
+ done
+ exit $fail
+ ' gate "$WATCHDOG"
+
+ # ── 3. the seam suites (fake shards + the real frontdoor as a subprocess) ─────
+ run_check "frontdoor integration suite (the seams)" python3 "$HERE/test_frontdoor.py"
+ run_check "jev placement unit suite" python3 "$HERE/test_jev_placement.py"
+fi
# ── 4. engine scope: the materialize/overlay/engine lanes ────────────────────
if [ "$SCOPE" = "engine" ] || [ "$SCOPE" = "all" ]; then
@@ -118,7 +138,7 @@ if [ -n "$STAGED_BIN" ]; then
local pid out code fail=0
XDG_DATA_HOME="$SMOKE_DATA" XDG_CONFIG_HOME="$SMOKE_CFG" "$STAGED_BIN" serve --port "$SMOKE_PORT" --hostname 127.0.0.1 >/dev/null 2>&1 &
pid=$!
- for i in $(seq 1 30); do
+ for _ in $(seq 1 30); do
curl -sS -o /dev/null -m 1 "http://127.0.0.1:$SMOKE_PORT/config?directory=%2Fhome%2Faaron%2Fharmoniqs%2Fopencode" 2>/dev/null && break
sleep 1
done
@@ -129,17 +149,41 @@ if [ -n "$STAGED_BIN" ]; then
# the SSE reconnect contract: frames must carry id: lines
out="$(timeout 4 curl -sN -m 4 "http://127.0.0.1:$SMOKE_PORT/event?directory=%2Fhome%2Faaron%2Fharmoniqs%2Fopencode" 2>/dev/null | head -2)"
echo "$out" | grep -q "^id: " || { echo " SSE frames carry no id: lines (replay contract broken)"; fail=1; }
- kill "$pid" 2>/dev/null
+ smoke_stop "$pid" || fail=1
+ return "$fail"
+ }
+ # smoke_stop : TERM, poll kill -0 for up to TERM_WAIT s, then KILL (#1944).
+ # Never a bare `wait` on a live server: that is the hang this replaces.
+ smoke_stop() {
+ local pid="$1" limit="${AMICODE_GATE_SMOKE_TERM_WAIT:-10}" ticks=0
+ kill -TERM "$pid" 2>/dev/null || { wait "$pid" 2>/dev/null; return 0; }
+ while kill -0 "$pid" 2>/dev/null; do
+ if [ "$ticks" -ge $((limit * 10)) ]; then
+ kill -KILL "$pid" 2>/dev/null
+ wait "$pid" 2>/dev/null
+ echo " staged binary ignored SIGTERM for ${limit}s (#1944) — killed"
+ return 1
+ fi
+ sleep 0.1
+ ticks=$((ticks + 1))
+ done
wait "$pid" 2>/dev/null
- return $fail
+ return 0
}
+ SMOKE_FAILED_BEFORE="$FAILED"; FAILED=0
run_check "staged binary boot smoke (isolated XDG, port $SMOKE_PORT)" smoke
+ SMOKE_OK=$(( FAILED == 0 )); FAILED=$(( SMOKE_FAILED_BEFORE || FAILED ))
rm -rf "$SMOKE_DATA" "$SMOKE_CFG"
# the smoke pass is the STAMP OF APPROVAL for this exact binary: hub-restart
# stage accepts the canonical sha OR this one (a gate-approved NEW build),
- # while still refusing binaries that never passed a gate.
- sha256sum "$STAGED_BIN" | awk '{print $1}' > "$HOME/.amico/server/bin/opencode.GATE-APPROVED-sha"
- echo "gate-approved sha recorded: $(sha256sum "$STAGED_BIN" | cut -c1-16)…"
+ # while still refusing binaries that never passed a gate. A FAILED smoke
+ # (incl. an ignored SIGTERM) records nothing; nor does a smoke-only run.
+ if [ "$SMOKE_OK" = "1" ] && [ "$SMOKE_ONLY" != "1" ]; then
+ sha256sum "$STAGED_BIN" | awk '{print $1}' > "$HOME/.amico/server/bin/opencode.GATE-APPROVED-sha"
+ echo "gate-approved sha recorded: $(sha256sum "$STAGED_BIN" | cut -c1-16)…"
+ else
+ echo "no gate approval recorded (smoke ok=$SMOKE_OK, smoke-only=$SMOKE_ONLY)"
+ fi
fi
# ── the panel render gate (#1804) ────────────────────────────────────────────
diff --git a/ops/hub/hub-frontdoor.py b/ops/hub/hub-frontdoor.py
index 440a00ea4..09481dbf6 100644
--- a/ops/hub/hub-frontdoor.py
+++ b/ops/hub/hub-frontdoor.py
@@ -2701,17 +2701,22 @@ def waiter():
PORT = int(_env("AMICODE_FRONTDOOR_PORT", "4096"))
except ValueError:
PORT = 4096
+# Bind address: LOOPBACK by default. Remote people reach the hub through
+# `tailscale serve` (tailnet-only HTTPS + verified identity headers) or an ssh
+# tunnel, never an open LAN port. AMICODE_FRONTDOOR_BIND=0.0.0.0 restores the
+# old exposure explicitly (logged).
+BIND = _env("AMICODE_FRONTDOOR_BIND", "127.0.0.1") or "127.0.0.1"
srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-srv.bind(("0.0.0.0", PORT)); srv.listen(128)
+srv.bind((BIND, PORT)); srv.listen(128)
# 2026-09-19 origin shift: fleet clients' Chromium HTTP cache holds the
# binary-era index (stored without revalidation headers) and self-assembles
# the whole app from cache on every reload — app updates NEVER reach them.
# The app now also listens on 4097; fleet.json points clients there. A fresh
# origin has no cache anywhere. 4096 stays for the watchdog + stragglers.
srv2 = socket.socket(); srv2.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-srv2.bind(("0.0.0.0", PORT + 1)); srv2.listen(128)
-log("front-door v3 (sharded-router, #1717 phase 1) listening %d+%d -> %s"
- % (PORT, PORT + 1,
+srv2.bind((BIND, PORT + 1)); srv2.listen(128)
+log("front-door v3 (sharded-router, #1717 phase 1) listening %s:%d+%d -> %s"
+ % (BIND, PORT, PORT + 1,
("%d shards, default %d" % (len(TABLE["backends"]), TABLE["default_shard"])) if SHARDED
else ("%s:%d (legacy)" % (BACKEND[0], BACKEND[1]))))
cid = 0
diff --git a/ops/hub/test_deploy_gate.py b/ops/hub/test_deploy_gate.py
new file mode 100644
index 000000000..bd6a1edd4
--- /dev/null
+++ b/ops/hub/test_deploy_gate.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""test_deploy_gate.py — stdlib suite for deploy-gate.sh's staged boot smoke (amicode #1949, #1944).
+
+Runs the REAL gate with AMICODE_GATE_SMOKE_ONLY=1 (only the staged smoke — no
+seam suites, no engine lanes, no approval stamp) against FAKE "staged binaries":
+small Python servers that answer the smoke's HTTP paths (config, session,
+question, permission → 200) and stream SSE frames with `id:` lines. One fake
+traps/ignores SIGTERM — the #1944 hang: the old `kill; wait` blocked forever.
+The gate must now finish in ~TERM_WAIT (10s) + the smoke's own probes, and
+name the failure. HOME is a temp dir, so nothing under ~/.amico is touched —
+safe on the live hub.
+
+Needs GNU `timeout` (the smoke uses it); on macOS without coreutils the smoke
+cases skip. Run it on the hub from a /tmp scratch copy:
+ rsync -a ops/hub/ amico-hub:/tmp/gate-test/
+ ssh amico-hub 'python3 /tmp/gate-test/test_deploy_gate.py'
+
+Run: python3 ops/hub/test_deploy_gate.py (exit 0 on pass)
+AMICODE_SHELLCHECK= points the static check at a shellcheck not on PATH.
+"""
+import os
+import shutil
+import signal
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SCRIPT = os.path.join(HERE, "deploy-gate.sh")
+NAMED_FAILURE = "staged binary ignored SIGTERM for 10s (#1944) — killed"
+
+# The fake staged binary: invoked as ` serve --port P --hostname 127.0.0.1`.
+# IGNORE_TERM is substituted per fake. It records its pid so the test can prove
+# the gate left nothing running.
+FAKE_SERVER = r'''#!/usr/bin/env python3
+import os, signal, sys, time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+IGNORE_TERM = __IGNORE_TERM__
+if IGNORE_TERM:
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+args = sys.argv[1:]
+port = int(args[args.index("--port") + 1])
+with open(os.environ["FAKE_PIDFILE"], "w") as f:
+ f.write(str(os.getpid()))
+OK = {"config", "session", "question", "permission"}
+
+class H(BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_GET(self):
+ path = self.path.lstrip("/").split("?")[0]
+ if path == "event":
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.end_headers()
+ try:
+ n = 0
+ while n < 100: # bounded: ~20s of heartbeats at most
+ n += 1
+ self.wfile.write(f"id: {n}\ndata: {{}}\n\n".encode())
+ self.wfile.flush()
+ time.sleep(0.2)
+ except (BrokenPipeError, ConnectionResetError):
+ pass
+ return
+ body = b"{}" if path in OK else b"no"
+ self.send_response(200 if path in OK else 404)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+srv = ThreadingHTTPServer(("127.0.0.1", port), H)
+srv.daemon_threads = True
+srv.serve_forever()
+'''
+
+
+def free_port():
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def alive(pid):
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ return True
+
+
+@unittest.skipUnless(shutil.which("timeout") and shutil.which("curl"), "the smoke needs GNU timeout + curl — run on the hub")
+class SmokeShutdownTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="deploy-gate-test-")
+ self.home = os.path.join(self.tmp, "home")
+ os.makedirs(os.path.join(self.home, ".amico", "server", "bin"))
+ self.pidfile = os.path.join(self.tmp, "fake.pid")
+
+ def tearDown(self):
+ if os.path.exists(self.pidfile):
+ with open(self.pidfile) as f:
+ pid = int(f.read().strip() or 0)
+ if pid and alive(pid):
+ os.kill(pid, signal.SIGKILL)
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def fake(self, ignore_term):
+ p = os.path.join(self.tmp, "fake-opencode")
+ with open(p, "w") as f:
+ f.write(FAKE_SERVER.replace("__IGNORE_TERM__", "True" if ignore_term else "False"))
+ os.chmod(p, 0o755)
+ return p
+
+ def run_gate(self, binary):
+ env = dict(os.environ)
+ env.update({"HOME": self.home, "AMICODE_GATE_SMOKE_ONLY": "1", "AMICODE_GATE_SMOKE_PORT": str(free_port()),
+ "FAKE_PIDFILE": self.pidfile})
+ env.pop("AMICODE_GATE_SMOKE_TERM_WAIT", None)
+ t0 = time.monotonic()
+ r = subprocess.run(["bash", SCRIPT, "--staged-binary", binary], capture_output=True, text=True, env=env, timeout=60)
+ return r.returncode, r.stdout + r.stderr, time.monotonic() - t0
+
+ def fake_pid(self):
+ with open(self.pidfile) as f:
+ return int(f.read().strip())
+
+ def approval_written(self):
+ return os.path.exists(os.path.join(self.home, ".amico", "server", "bin", "opencode.GATE-APPROVED-sha"))
+
+ def test_server_ignoring_sigterm_is_a_named_smoke_failure_not_a_hang(self):
+ code, out, elapsed = self.run_gate(self.fake(ignore_term=True))
+ self.assertEqual(code, 1, out)
+ self.assertIn(NAMED_FAILURE, out)
+ self.assertIn("FAIL << this blocks the deploy", out)
+ self.assertIn("GATE FAILED", out)
+ self.assertLess(elapsed, 20, f"the gate took {elapsed:.1f}s — the bounded wait is 10s\n{out}")
+ self.assertGreaterEqual(elapsed, 10, "the server got its full TERM window before KILL")
+ self.assertFalse(alive(self.fake_pid()), "the TERM-ignoring server was KILLed, not left running")
+ self.assertFalse(self.approval_written(), "a failed smoke records no gate approval")
+ print(f"\n TERM-ignoring fake: gate exited {code} in {elapsed:.1f}s", file=sys.stderr)
+
+ def test_well_behaved_server_passes_promptly(self):
+ code, out, elapsed = self.run_gate(self.fake(ignore_term=False))
+ self.assertEqual(code, 0, out)
+ self.assertIn("GATE GREEN", out)
+ self.assertNotIn("ignored SIGTERM", out)
+ self.assertLess(elapsed, 15, out)
+ self.assertFalse(alive(self.fake_pid()))
+ self.assertFalse(self.approval_written(), "a smoke-only run never records a gate approval")
+ print(f"\n well-behaved fake: gate exited {code} in {elapsed:.1f}s", file=sys.stderr)
+
+ def test_smoke_only_without_a_staged_binary_is_a_usage_refusal(self):
+ env = dict(os.environ, HOME=self.home, AMICODE_GATE_SMOKE_ONLY="1")
+ r = subprocess.run(["bash", SCRIPT], capture_output=True, text=True, env=env, timeout=30)
+ self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
+
+
+class StaticChecks(unittest.TestCase):
+ def test_bash_syntax(self):
+ r = subprocess.run(["bash", "-n", SCRIPT], capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_no_unbounded_wait_on_the_smoke_server(self):
+ with open(SCRIPT) as f:
+ src = f.read()
+ self.assertNotIn('kill "$pid" 2>/dev/null\n wait "$pid"', src, "the #1944 hang shape is back")
+ self.assertIn("kill -KILL", src)
+
+ @unittest.skipUnless(os.environ.get("AMICODE_SHELLCHECK") or shutil.which("shellcheck"), "shellcheck not installed")
+ def test_shellcheck(self):
+ sc = os.environ.get("AMICODE_SHELLCHECK") or shutil.which("shellcheck")
+ r = subprocess.run([sc, SCRIPT], capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/ops/hub/test_frontdoor_bind.py b/ops/hub/test_frontdoor_bind.py
new file mode 100644
index 000000000..9f429bdc7
--- /dev/null
+++ b/ops/hub/test_frontdoor_bind.py
@@ -0,0 +1,46 @@
+"""The frontdoor binds loopback by default; LAN exposure is an explicit opt-in."""
+import os, socket, subprocess, sys, time, unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+
+def free_pair():
+ for _ in range(50):
+ a = socket.socket(); a.bind(("127.0.0.1", 0)); p = a.getsockname()[1]; a.close()
+ try:
+ b = socket.socket(); b.bind(("0.0.0.0", p + 1)); b.close(); return p
+ except OSError:
+ continue
+ raise RuntimeError("no free port pair")
+
+
+def listening_addr(port, timeout=10):
+ end = time.time() + timeout
+ while time.time() < end:
+ out = subprocess.run(["ss", "-ltnH", f"sport = :{port}"], capture_output=True, text=True).stdout
+ if out.strip():
+ return out.split()[3].rsplit(":", 1)[0]
+ time.sleep(0.2)
+ return None
+
+
+class FrontdoorBind(unittest.TestCase):
+ def run_fd(self, env_extra):
+ port = free_pair()
+ env = dict(os.environ, AMICODE_FRONTDOOR_PORT=str(port), **env_extra)
+ p = subprocess.Popen([sys.executable, os.path.join(HERE, "hub-frontdoor.py")], env=env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ try:
+ return listening_addr(port), listening_addr(port + 1)
+ finally:
+ p.terminate(); p.wait(timeout=10)
+
+ def test_default_is_loopback(self):
+ self.assertEqual(self.run_fd({}), ("127.0.0.1", "127.0.0.1"))
+
+ def test_explicit_lan_opt_in(self):
+ self.assertEqual(self.run_fd({"AMICODE_FRONTDOOR_BIND": "0.0.0.0"}), ("0.0.0.0", "0.0.0.0"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/packages/amico-run/package.json b/packages/amico-run/package.json
index 57026d723..0108ea01b 100644
--- a/packages/amico-run/package.json
+++ b/packages/amico-run/package.json
@@ -26,7 +26,7 @@
},
"dependencies": {
"@amicode/schema": "workspace:*",
- "smol-toml": "^1.8.0",
+ "smol-toml": "^1.9.1",
"yaml": "^2.9.1"
},
"devDependencies": {
diff --git a/packages/amico-run/src/corrections_verb.ts b/packages/amico-run/src/corrections_verb.ts
new file mode 100644
index 000000000..9a6a46d53
--- /dev/null
+++ b/packages/amico-run/src/corrections_verb.ts
@@ -0,0 +1,833 @@
+// corrections_verb.ts — `amico corrections scan` (#1677): mine the chat DB for
+// user messages that CORRECT system behavior (the pasqal-campaign lesson: the
+// user's in-chat corrections are the highest-signal feedback the system gets,
+// and they used to evaporate), classify the residual with the ONE Jev client,
+// and file skills-integrity findings at `status: open` in the personal vault.
+//
+// DOCTRINE (the #1301/#1303/#1311 curation discipline, unchanged):
+// deterministic rules are the front line — the marker pre-filter owns RECALL
+// (a generous correction-signal regex + the junk-title vocabulary decide
+// WHICH messages are worth a call); Jev owns PRECISION and the kind/severity
+// labels, on the residual only, through the shared jev_client.ts (never a
+// second client); one JSONL receipt per attempted call (the client's job);
+// fail-open everywhere — no key, disabled, outage, or sub-threshold confidence
+// degrades to digest-only, never to a failed pass.
+//
+// GATE (the skills-integrity charter): detection is the autonomous fast ring —
+// this verb files `open` findings and NOTHING more. Triage, fixes, proposals,
+// merges: human-gated, always. `not-a-correction` never files; below-threshold
+// confidence never files; unverified (jev unavailable) never files.
+//
+// CONVENTIONS: DRY-RUN BY DEFAULT (`--apply` writes findings + watermark +
+// digest + Slack ping — the sessions-verb house rule); the chat DB opens
+// READ-ONLY (`--db` flag → $OPENCODE_DB → the XDG default; sqlite_bridge.ts —
+// never a writable connection for the scan); the pass watermark lives in the
+// ops dir and applies ONLY when `--days` is not explicit (a backfill --days N
+// forces the full window, per the issue's AC); vault writes target the
+// personal mount (the amico-vault routing rule) — `--vault` overrides for
+// tests, $AMICO_VAULTS_ROOT/$AMICO_VAULT_DIR already flow through mounts.ts.
+import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
+import { mkdtempSync, rmSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { askJev, jevDisabled, type JevDeps, type JevQuestion } from "./jev_client.js";
+import type { JevPassStatus } from "./jev_curation.js";
+import { isGreetingTitle } from "./session_junk.js";
+import { amicodeOpsDir } from "./session_retention.js";
+import { personalMount, resolveMountStack } from "./mounts.js";
+import { sqliteBatch } from "./sqlite_bridge.js";
+import { resolveSessionDb } from "./sessions_verb.js";
+import type { VerbResult } from "./verbs.js";
+
+const USAGE =
+ "amico corrections scan [--days ] [--apply] [--dry-run] [--max-jev ] [--db ] [--vault ] [--post ]";
+
+// ── the gates + budgets (named exports — no magic numbers at call sites) ──────
+
+/** Default scan window: the last 7 days (rolling; the watermark narrows it). */
+export const DEFAULT_SCAN_DAYS = 7;
+
+/** Filing gate — the calibrated Choice confidence floor. Filing an OPEN
+ * finding is non-destructive (a human triages it), so this sits well under
+ * the archiver's destructive 0.95. Tuned against the pasqal campaign backfill
+ * (#1677's acceptance run): jev reads true corrections as kind=behavior-gap
+ * with noul ≥ 0.94 but kind-confidence in the 0.80–0.90 band — a 0.90 gate
+ * starved exactly the corrections the scan exists for. The noul floor below
+ * is the corroboration that keeps 0.80 honest (true corrections read
+ * noul ≥ 0.9; not-a-corrections read ≤ 0.5 on the same fold). */
+export const CORRECTIONS_CONFIDENCE_MIN = 0.80;
+
+/** Corroboration floor — the correction-noul must agree (p(true) ≥ 0.5). */
+export const CORRECTIONS_NOUL_MIN = 0.5;
+
+/** Jev budget: at most this many residual consultations per pass (the
+ * pre-filter is generous by design — the cap is the cost guard). Kept low
+ * while the classifier path is in preview; raise per pass with `--max-jev`. */
+export const DEFAULT_MAX_JEV = 50;
+
+/** Digest unverified-entries cap (digest rows, never findings). */
+export const DIGEST_UNVERIFIED_CAP = 20;
+
+// ── the deterministic front line (recall) ────────────────────────────────────
+
+/** Correction-signal markers — ONE match consults Jev. Generous by design:
+ * the regex owns recall, Jev owns precision; `--max-jev` caps the spend.
+ * Tuned against the pasqal campaign session (amicode #1677's acceptance
+ * backfill): interrogative corrections ("why are we…", "are we not…"),
+ * prescriptive ones ("we should…"), and the stack's display-drift
+ * vocabulary ("not showing", "not in line") are all recall-relevant. */
+export const CORRECTION_MARKERS: RegExp[] = [
+ /\bno[ ,:;!?]/i,
+ /\bnope\b/i,
+ /\b(?:that'?s|that is|thats) (?:not|wrong)\b/i,
+ /\byou'?re (?:wrong|misunderstanding|doing it wrong)\b/i,
+ /\b(?:misunderstood|misunderstanding)\b/i,
+ /\b(?:wrong|incorrect)\b/i,
+ /\bnot what i\b/i,
+ /\bi (?:said|told you|asked for|meant|wanted)\b/i,
+ /\bi didn'?t (?:ask|say|want)\b/i,
+ /\bstop\b/i,
+ /\bdon'?t\b/i,
+ /\binstead\b/i,
+ /\bwe should(?:n'?t)?\b/i,
+ /\byou should(?:n'?t)?\b/i,
+ /\bshould be (?:using|doing)\b/i,
+ /\bshouldn'?t\b/i,
+ /\b(?:supposed to|you were supposed)\b/i,
+ /\bwhy (?:did|are|is) (?:you|we|it)\b/i,
+ /\bare we not\b/i,
+ /\bwtf\b/i,
+ /\bway too\b/i,
+ /\bnot (?:showing|in line|visible)\b/i,
+ /\b(?:again|still) (?:wrong|not)\b/i,
+ /\bstill (?:can'?t|don'?t)\b/i,
+ /\bper my\b/i,
+ /\bas i said\b/i,
+ /\b(?:explicitly|specifically) (?:said|asked)\b/i,
+ /\bwe agreed\b/i,
+ /\bfor the (?:last|third) time\b/i,
+ /\bi explicitly\b/i,
+ /\bnot again\b/i,
+];
+
+/** The pre-filter (pure): one marker match ⇒ a Jev consultation candidate. */
+export function isCorrectionCandidate(text: string): boolean {
+ return CORRECTION_MARKERS.some((re) => re.test(text));
+}
+
+// ── the Jev middle layer (precision + labels) ─────────────────────────────────
+
+/** The closed kind set — the four-way rubric; `not-a-correction` never files. */
+export const CORRECTION_KINDS = ["skill-drift", "skill-gap", "behavior-gap", "not-a-correction"] as const;
+export type CorrectionKind = (typeof CORRECTION_KINDS)[number];
+
+export const KIND_RUBRIC: Record = {
+ "skill-drift":
+ "the user is correcting behavior that a documented skill/instruction told the system to do — the documented behavior is wrong versus what the user wants or what reality does",
+ "skill-gap":
+ "the user is correcting behavior that no skill/instruction covers — a missing skill, a hand-rolled idiom the system improvised",
+ "behavior-gap":
+ "the user is correcting the agent's protocol or judgment — routing, campaign procedure, tool choice, how work is done — rather than a documented skill claim",
+ "not-a-correction":
+ "not a correction — new instructions, a clarification, a question, praise, thanks, or an unrelated remark",
+};
+
+/** The closed severity set — the skills-integrity ladder (P0 wrong-results →
+ * P3 gap). The Slack ping gate reads this, P0/P1 only. */
+export const SEVERITIES = ["p0", "p1", "p2", "p3"] as const;
+export type CorrectionSeverity = (typeof SEVERITIES)[number];
+
+export const SEVERITY_RUBRIC: Record = {
+ p0: "following the system's current behavior would produce WRONG RESULTS — corrupted physics, data, or conclusions",
+ p1: "the behavior is broken — the system failed at what it attempted, blocked work, or repeated a mistake that had already been corrected",
+ p2: "drift — the system works but contradicts its documented instructions or skills",
+ p3: "a gap — nothing covers the corrected behavior, but nothing broke",
+};
+
+/** One gather-eligible user message (the verb's DB rows map here OUTSIDE the
+ * Jev layer — the classifier stays a pure function of the candidate). */
+export interface CorrectionCandidate {
+ messageId: string;
+ sessionId: string;
+ sessionTitle: string;
+ timeCreated: number;
+ userText: string;
+ precedingAssistantText: string;
+}
+
+/** Byte-clip a string for the Jev state fold (the 4096-byte state cap — bytes,
+ * not chars; a CJK char is 3–4 bytes and a naive slice would bust the cap). */
+export function clipBytes(s: string, maxBytes: number): string {
+ const bytes = Buffer.byteLength(s, "utf8");
+ if (bytes <= maxBytes) return s;
+ let end = s.length;
+ while (end > 0 && Buffer.byteLength(s.slice(0, end), "utf8") > maxBytes) end--;
+ return s.slice(0, end);
+}
+
+const STATE_TITLE_BYTES = 200;
+const STATE_USER_BYTES = 1500;
+const STATE_ASSISTANT_BYTES = 2000;
+
+/** The three questions over one state fold (ONE askJev call per candidate —
+ * one receipt, one latency, three verdicts). Pure builder, exported for
+ * hermetic tests. */
+export function correctionsQuestions(c: CorrectionCandidate): {
+ questions: Record;
+ state: Record;
+} {
+ return {
+ questions: {
+ correction: {
+ type: "noul",
+ instructions:
+ "Is this user message a CORRECTION of what the assistant just did or said — telling it that it was wrong, should not have done that, or must do it differently?",
+ criteria: {
+ true: "the user is pushing back on, negating, or re-instructing behavior the assistant already produced — it did something wrong or unwanted",
+ false: "the user is giving fresh instructions, asking a question, clarifying politely, thanking, or changing the subject — not correcting anything already done",
+ },
+ },
+ kind: {
+ type: "choice",
+ instructions: "Classify the correction into exactly one kind. Judge by what the user is correcting, not by its tone.",
+ criteria: KIND_RUBRIC,
+ },
+ severity: {
+ type: "choice",
+ instructions: "How severe is the corrected behavior, on the skills-integrity ladder? Judge the consequence of the system's mistake, not its cause.",
+ criteria: SEVERITY_RUBRIC,
+ },
+ },
+ state: {
+ session_title: clipBytes(c.sessionTitle, STATE_TITLE_BYTES),
+ user_message: clipBytes(c.userText, STATE_USER_BYTES),
+ preceding_assistant_text: clipBytes(c.precedingAssistantText, STATE_ASSISTANT_BYTES),
+ },
+ };
+}
+
+/** The per-candidate Jev verdict (all three reads, or the honest error). */
+export interface CorrectionVerdict {
+ noul?: number;
+ kind?: string;
+ kind_confidence?: number;
+ severity?: string;
+ error?: string;
+}
+
+/** The filing gate (pure): a kind-carrying correction at ≥ 0.80 confidence
+ * (CORRECTIONS_CONFIDENCE_MIN — recalibrated by the pasqal backfill), corroborated
+ * by the noul at ≥ 0.5. `not-a-correction` never files; anything below
+ * threshold never files. */
+export function filingAdmits(kind: string | undefined, kindConfidence: number, noul: number): boolean {
+ if (kind === undefined) return false;
+ if (!(CORRECTION_KINDS as readonly string[]).includes(kind)) return false;
+ if (kind === "not-a-correction") return false;
+ return kindConfidence >= CORRECTIONS_CONFIDENCE_MIN && noul >= CORRECTIONS_NOUL_MIN;
+}
+
+/** Normalize a severity answer to the skills-integrity P-ladder label. */
+export function severityOf(sev: string | undefined): string {
+ const s = (sev ?? "").toLowerCase();
+ return (SEVERITIES as readonly string[]).includes(s) ? s.toUpperCase() : "P2";
+}
+
+/** P0/P1 severities ping Slack; everything else files silently. */
+export function pingWorthy(sev: string): boolean {
+ return sev === "P0" || sev === "P1";
+}
+
+const SEVERITY_RANK: Record = { P0: 0, P1: 1, P2: 2, P3: 3 };
+
+/** The cluster's severity is its WORST occurrence (a P2 cluster that escalates
+ * to P1 on re-correction files as P1 — the ping gate reads this, so a first-
+ * occurrence-severity would silently swallow the escalation). */
+export function maxSeverity(a: AdmittedCorrection[]): string {
+ return a.reduce((worst, o) => (SEVERITY_RANK[o.severity] < SEVERITY_RANK[worst] ? o.severity : worst), "P3");
+}
+
+/** The kind → finding-type mapping (TEMPLATE.md's two shapes): drift is a
+ * finding; gaps (skill or behavior) are proposals — repeated corrections ARE
+ * the proposal's evidence. */
+export function findingTypeOf(kind: CorrectionKind): "skill-finding" | "skill-proposal" {
+ return kind === "skill-drift" ? "skill-finding" : "skill-proposal";
+}
+
+/** One admitted correction occurrence (the provenance the templates demand). */
+export interface AdmittedCorrection {
+ candidate: CorrectionCandidate;
+ kind: CorrectionKind;
+ severity: string;
+ kindConfidence: number;
+ noul: number;
+}
+
+/** The cluster key: repeated corrections of the same behavior within one
+ * session collapse into ONE finding with N provenance-tagged occurrences
+ * (the skill-proposal claim format). */
+export function clusterKey(a: AdmittedCorrection): string {
+ return `${a.candidate.sessionId}:${a.kind}`;
+}
+
+// ── the gather (deterministic; READ-ONLY) ─────────────────────────────────────
+
+/** User messages that FOLLOW an assistant text turn, in top-level unarchived
+ * sessions, with their text parts concatenated and the preceding assistant
+ * text excerpt — the role/json_extract shapes the sessions verb + open-threads
+ * reader already use against this DB. */
+export const CORRECTIONS_GATHER_SQL = `
+ SELECT m.id AS message_id, m.session_id, s.title, m.time_created,
+ (SELECT group_concat(json_extract(p.data, '$.text'), ' ')
+ FROM part p
+ WHERE p.message_id = m.id
+ AND json_extract(p.data, '$.type') = 'text'
+ AND json_extract(p.data, '$.text') IS NOT NULL) AS user_text,
+ (SELECT json_extract(p2.data, '$.text') FROM part p2
+ JOIN message m2 ON m2.id = p2.message_id
+ WHERE p2.session_id = m.session_id
+ AND json_extract(m2.data, '$.role') = 'assistant'
+ AND json_extract(p2.data, '$.type') = 'text'
+ AND length(json_extract(p2.data, '$.text')) > 0
+ AND m2.time_created < m.time_created
+ ORDER BY p2.time_created DESC LIMIT 1) AS prev_assistant_text
+ FROM message m
+ JOIN session s ON s.id = m.session_id
+ WHERE json_extract(m.data, '$.role') = 'user'
+ AND s.parent_id IS NULL
+ AND s.time_archived IS NULL
+ AND m.time_created > ?
+ ORDER BY m.time_created, m.id`;
+
+// ── the watermark (ops dir; applies only without an explicit --days) ───────────
+
+export function correctionsWatermarkFile(env: NodeJS.ProcessEnv): string {
+ return join(amicodeOpsDir(env), "corrections-watermark.json");
+}
+
+export function readWatermarkMs(env: NodeJS.ProcessEnv): number | undefined {
+ try {
+ const parsed = JSON.parse(readFileSync(correctionsWatermarkFile(env), "utf8")) as { last_scanned_ms?: unknown };
+ const ms = Number(parsed.last_scanned_ms);
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+export function writeWatermarkMs(env: NodeJS.ProcessEnv, ms: number): void {
+ const file = correctionsWatermarkFile(env);
+ mkdirSync(dirname(file), { recursive: true });
+ writeFileSync(file, JSON.stringify({ schema_version: 1, last_scanned_ms: ms, updated_at: new Date(ms).toISOString() }, null, 2) + "\n");
+}
+
+/** The effective window (pure): the --days cutoff, narrowed by the watermark
+ * ONLY when --days was not explicitly passed (a backfill --days forces the
+ * full window, per the issue's AC — and the nightly job runs without --days). */
+export function effectiveSinceMs(now: number, days: number, daysExplicit: boolean, watermarkMs: number | undefined): number {
+ const cutoff = now - days * 86_400_000;
+ if (daysExplicit || watermarkMs === undefined) return cutoff;
+ return Math.max(cutoff, watermarkMs);
+}
+
+// ── the findings writer (personal vault; TEMPLATE.md shapes) ───────────────────
+
+/** Resolve the scan's vault root (the personal mount — the amico-vault
+ * routing rule): `--vault` flag, else the resolved personal mount, else
+ * undefined (and the scan reports it honestly instead of guessing). */
+export function resolveVaultRoot(argv: string[], env: NodeJS.ProcessEnv): string | undefined {
+ const i = argv.indexOf("--vault");
+ if (i >= 0 && i + 1 < argv.length) return argv[i + 1]!;
+ const mount = personalMount(resolveMountStack());
+ return mount === undefined ? undefined : mount.path;
+}
+
+export function findingsDirOf(vaultRoot: string): string {
+ return join(vaultRoot, "amicode", "skills-integrity", "findings");
+}
+
+export function digestFileOf(vaultRoot: string): string {
+ return join(vaultRoot, "dashboards", "user-corrections.md");
+}
+
+export const DIGEST_HEADER = `---
+type: dashboard
+subtype: user-corrections
+source: corrections-scan
+---
+
+# User corrections
+
+One section per corrections-scan pass, newest at the bottom. Auto-maintained by
+\`amico corrections scan --apply\`; triage happens in the skills-integrity loop,
+application stays human-gated.
+`;
+
+function slugOf(s: string): string {
+ const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
+ return slug === "" ? "correction" : slug;
+}
+
+/** Escape untrusted text for a double-quoted YAML scalar — user messages are
+ * attacker-controlled bytes from the chat DB: a bare `"` would end the
+ * scalar early and let the message inject frontmatter keys (a `status:
+ * fixed` line inside evidence would poison both Obsidian and the dedup
+ * matcher). Newlines are collapsed first; backslash and quote are escaped. */
+function yamlScalar(s: string): string {
+ return s.replace(/\s+/g, " ").trim().replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+}
+
+/** Atomic vault write: tmp sibling + rename — a crash mid-write must never
+ * truncate a finding (the TEMPLATE rule: the original claim is never
+ * rewritten, and a torn write rewrites it the hard way). The house pattern
+ * (thread-noul's map write, slack_verb's token write). */
+function atomicWrite(target: string, content: string): void {
+ mkdirSync(dirname(target), { recursive: true });
+ const tmp = join(dirname(target), `.${randomBytes(6).toString("hex")}.tmp`);
+ try {
+ writeFileSync(tmp, content);
+ renameSync(tmp, target);
+ } finally {
+ try {
+ rmSync(tmp, { force: true });
+ } catch {
+ // rename moved it; a failed rm on a nonexistent tmp is fine
+ }
+ }
+}
+
+function isoDate(ms: number): string {
+ return new Date(ms).toISOString().slice(0, 10);
+}
+
+function quoteBlock(text: string, maxChars = 400): string {
+ const t = text.replace(/\s+/g, " ").trim();
+ return `> ${t.length > maxChars ? `${t.slice(0, maxChars)}…` : t}`;
+}
+
+/** The scan's id namespace — collision-free against the human Fxx/Sxx scheme
+ * (the CS prefix says "auto-filed by corrections-scan" on its face). */
+export function scanId(seq: number): string {
+ return `CS-${seq}`;
+}
+
+/** Render one finding note per the skills-integrity TEMPLATE shapes (drift →
+ * skill-finding; gaps → skill-proposal with the occurrences as evidence).
+ * `seq` is the pass-scoped sequence — the CS- id namespace. */
+export function renderFinding(a: AdmittedCorrection[], passIso: string, seq: number): { filename: string; content: string } {
+ const first = a[0]!;
+ const date = isoDate(first.candidate.timeCreated);
+ const slug = slugOf(first.candidate.sessionTitle);
+ const type = findingTypeOf(first.kind);
+ const id = scanId(seq);
+ const occurrences = a
+ .map(
+ (o) =>
+ `- ${o.candidate.sessionId} ${new Date(o.candidate.timeCreated).toISOString()} (kind ${o.kind}, ${o.severity}, confidence ${o.kindConfidence.toFixed(2)}):\n` +
+ ` ${quoteBlock(o.candidate.userText)}` +
+ (o.candidate.precedingAssistantText.trim() !== "" ? `\n correcting:\n ${quoteBlock(o.candidate.precedingAssistantText, 200)}` : ""),
+ )
+ .join("\n");
+ const autoNote = `\nAuto-filed by \`amico corrections scan\` (pass ${passIso}) — kind \`${first.kind}\`, severity ${a.map((o) => o.severity).join("/")}, Jev confidence ${first.kindConfidence.toFixed(2)}, noul ${first.noul.toFixed(2)}. Triage: the skills-integrity loop, step 3. Application is human-gated.\n`;
+ const severity = maxSeverity(a);
+ const frontmatter =
+ type === "skill-finding"
+ ? [
+ "---",
+ "type: skill-finding",
+ `date: ${date}`,
+ `finding_id: ${id}`,
+ `severity: ${severity}`,
+ "status: open",
+ `session_id: "${yamlScalar(first.candidate.sessionId)}"`,
+ `source: "corrections-scan pass ${passIso}"`,
+ "skills: []",
+ `tags: [corrections-scan, ${first.kind}]`,
+ `correction_kind: ${first.kind}`,
+ "corrections_scan: true",
+ "---",
+ "",
+ ].join("\n")
+ : [
+ "---",
+ "type: skill-proposal",
+ `date: ${date}`,
+ `proposal_id: ${id}`,
+ "status: open",
+ `session_id: "${yamlScalar(first.candidate.sessionId)}"`,
+ "evidence:",
+ ...a.map((o) => ` - "${yamlScalar(`${o.candidate.sessionId} ${new Date(o.candidate.timeCreated).toISOString()}: ${o.candidate.userText.slice(0, 160)}`)}"`),
+ `tags: [corrections-scan, new-skill, ${first.kind}]`,
+ `correction_kind: ${first.kind}`,
+ "corrections_scan: true",
+ "---",
+ "",
+ ].join("\n");
+ const body =
+ type === "skill-finding"
+ ? `**Claim** — the system's documented behavior was corrected by the user (skill-drift, per the scan's classification).\n\n**User correction (verbatim):**\n${occurrences}\n\n**Operational consequence** — TBD by triage.\n**Fix shape** — TBD by triage.\n${autoNote}`
+ : `**The idiom** — the user had to correct un-skilled system behavior (${first.kind}); the occurrences below are the proposal's evidence.\n\n**Occurrences (${a.length}):**\n${occurrences}\n\n**Proposed skill purpose** — cover the corrected behavior so it stops being improvised.\n**Nearest existing skill** — TBD by triage.\n${autoNote}`;
+ return { filename: `${type === "skill-finding" ? "finding" : "proposal"}-${date}-cs${seq}-${slug}.md`, content: frontmatter + body };
+}
+
+/** Find an existing OPEN scan finding for the same session + kind — the
+ * cross-pass dedup (append an UPDATE, never a duplicate file). Scans ONLY
+ * the frontmatter block: the body quotes user text verbatim, and matching
+ * against the body would let a pasted finding-like message inside one
+ * finding's quotes match a different (session, kind) pair. */
+export function findExistingScanFinding(dir: string, sessionId: string, kind: string): string | undefined {
+ if (!existsSync(dir)) return undefined;
+ for (const name of readdirSync(dir).sort()) {
+ if (!name.endsWith(".md")) continue;
+ let content: string;
+ try {
+ content = readFileSync(join(dir, name), "utf8");
+ } catch {
+ continue;
+ }
+ // the frontmatter block only: between the opening and closing --- lines
+ const fm = /^---\n([\s\S]*?)\n---/.exec(content)?.[1];
+ if (fm === undefined) continue;
+ if (!fm.includes("corrections_scan: true")) continue;
+ if (!fm.includes(`correction_kind: ${kind}`)) continue;
+ if (!fm.includes(`session_id: "${yamlScalar(sessionId)}"`)) continue;
+ if (!/\bstatus:\s*open\b/.test(fm)) continue;
+ return join(dir, name);
+ }
+ return undefined;
+}
+
+/** The UPDATE section appended to an existing open finding (TEMPLATE rule:
+ * updates append; the original claim is never rewritten). */
+export function renderUpdate(a: AdmittedCorrection[], passIso: string): string {
+ const first = a[0]!;
+ return `\n## UPDATE ${isoDate(Date.now())}\n\nCorrections-scan pass ${passIso} re-observed this correction:\n\n- ${first.candidate.sessionId} ${new Date(first.candidate.timeCreated).toISOString()} (kind ${first.kind}, ${first.severity}, confidence ${first.kindConfidence.toFixed(2)}):\n ${quoteBlock(first.candidate.userText)}\n`;
+}
+
+/** Render one digest dashboard section for a pass. */
+export function renderDigestSection(p: {
+ passIso: string;
+ days: number;
+ sinceIso: string;
+ scanned: number;
+ candidates: number;
+ jevStatus: string;
+ filed: { path: string; type: string; kind: string; severity: string; occurrences: number }[];
+ unverified: { sessionId: string; excerpt: string }[];
+ unverifiedOverflow: number;
+ overflow: number;
+ pinged: string | undefined;
+}): string {
+ const lines = [
+ `## Pass ${p.passIso} — corrections-scan`,
+ "",
+ `- window: ${p.days}d (since ${p.sinceIso}); scanned ${p.scanned} messages, ${p.candidates} correction candidates; jev: ${p.jevStatus}`,
+ `- filed: ${p.filed.length} finding(s)${p.filed.length > 0 ? ` — ${p.filed.map((f) => `${f.type} ${f.severity} ${f.kind} (${f.occurrences}×) ${f.path}`).join("; ")}` : ""}`,
+ ];
+ if (p.unverified.length > 0 || p.unverifiedOverflow > 0) {
+ lines.push(`- unverified candidates (jev ${p.jevStatus} — nothing filed, listed for the eye): ${p.unverified.length + p.unverifiedOverflow}`);
+ for (const u of p.unverified) lines.push(` - ${u.sessionId}: ${u.excerpt}`);
+ if (p.unverifiedOverflow > 0) lines.push(` - …and ${p.unverifiedOverflow} more`);
+ }
+ if (p.overflow > 0) lines.push(`- overflow: ${p.overflow} candidate(s) past the --max-jev cap were not judged this pass`);
+ if (p.pinged !== undefined) lines.push(`- slack: P0/P1 ping sent to ${p.pinged}`);
+ lines.push("");
+ return lines.join("\n");
+}
+
+// ── the Slack ping (the amico-slack subprocess contract — fleet_digest's) ──────
+
+/** $AMICO_CORRECTIONS_SLACK_CHANNEL; --post wins over it, absent → no ping. */
+export function pingChannel(argv: string[], env: NodeJS.ProcessEnv): string {
+ const i = argv.indexOf("--post");
+ if (i >= 0 && i + 1 < argv.length) return argv[i + 1]!;
+ const v = env.AMICO_CORRECTIONS_SLACK_CHANNEL;
+ return v && v.trim() !== "" ? v.trim() : "";
+}
+
+/** The subprocess contract: `amico-slack send --file ` (no Slack API
+ * code here — the same contract fleet_digest posts through). */
+export function postViaAmicoSlack(channel: string, text: string): { ok: boolean; error?: string } {
+ const dir = mkdtempSync(join(tmpdir(), "amico-corrections-"));
+ try {
+ const file = join(dir, "ping.md");
+ writeFileSync(file, text, "utf8");
+ const r = spawnSync("amico-slack", ["send", channel, "--file", file], { encoding: "utf8", timeout: 60_000 });
+ if (r.error) {
+ const code = (r.error as NodeJS.ErrnoException).code;
+ return { ok: false, error: code === "ENOENT" ? "amico-slack not found on PATH" : String(r.error.message) };
+ }
+ if (r.status !== 0) {
+ const tail = ((r.stderr || "") + "\n" + (r.stdout || "")).trim().split("\n").pop() ?? "";
+ return { ok: false, error: tail || `amico-slack exit ${r.status}` };
+ }
+ return { ok: true };
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+// ── the scan deps (injectable — the suite runs hermetically) ───────────────────
+
+export interface CorrectionsDeps {
+ /** The middle-layer seam: default = the askJev loop below (receipts,
+ * fail-open); tests inject verdicts. */
+ jev?: (candidates: CorrectionCandidate[]) => Promise<{ status: JevPassStatus; verdicts: Record }>;
+ /** The Slack seam: default = the amico-slack subprocess contract. */
+ post?: (channel: string, text: string) => { ok: boolean; error?: string };
+}
+
+/** The default middle layer: ONE askJev call per candidate (three questions,
+ * one receipt), unavailable/disabled short-circuits the whole pass — the
+ * curation loop convention (no call is made after unavailability is known).
+ * Circuit breaker: 5 consecutive transport/http/malformed failures degrade
+ * the pass to `error` — even at the default --max-jev 50 a hanging endpoint
+ * must not cost 50 × 10 s of serial waiting; the remaining candidates read
+ * as unverified. */
+const JEV_CONSECUTIVE_FAILURE_BREAK = 5;
+
+async function jevCorrections(
+ candidates: CorrectionCandidate[],
+ deps: { env: NodeJS.ProcessEnv; jev?: JevDeps },
+): Promise<{ status: JevPassStatus | "error"; verdicts: Record }> {
+ const verdicts: Record = {};
+ let consecutiveFailures = 0;
+ for (const c of candidates) {
+ const { questions, state } = correctionsQuestions(c);
+ const res = await askJev(questions, state, { sessionId: c.sessionId, deps: deps.jev ?? { env: deps.env } });
+ if (!res.ok) {
+ if (res.reason === "disabled" || res.reason === "key-missing")
+ return { status: res.reason === "disabled" ? "disabled" : "unavailable", verdicts };
+ if (++consecutiveFailures >= JEV_CONSECUTIVE_FAILURE_BREAK) return { status: "error", verdicts };
+ verdicts[c.messageId] = { error: res.error };
+ continue;
+ }
+ consecutiveFailures = 0;
+ const correction = res.answers.correction;
+ const kind = res.answers.kind;
+ const severity = res.answers.severity;
+ if (correction === undefined || kind === undefined || severity === undefined || correction.type !== "noul" || kind.type !== "choice" || severity.type !== "choice") {
+ verdicts[c.messageId] = { error: "jev returned an incomplete answer set" };
+ continue;
+ }
+ verdicts[c.messageId] = { noul: correction.noul, kind: kind.choice, kind_confidence: kind.confidence, severity: severity.choice };
+ }
+ return { status: "ran", verdicts };
+}
+
+// ── the verb ──────────────────────────────────────────────────────────────────
+
+function flagValue(argv: string[], name: string): string | undefined {
+ const i = argv.indexOf(name);
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
+}
+
+function hasFlag(argv: string[], name: string): boolean {
+ return argv.includes(name);
+}
+
+function fail(error: string, extra: Record = {}): VerbResult {
+ return { json: { verb: "corrections", error, ...extra }, code: 64 };
+}
+
+export async function correctionsScan(argv: string[], env: NodeJS.ProcessEnv, deps: CorrectionsDeps = {}): Promise {
+ const daysFlag = flagValue(argv, "--days");
+ const days = daysFlag !== undefined ? Number(daysFlag) : DEFAULT_SCAN_DAYS;
+ if (!Number.isInteger(days) || days < 1) return fail(`--days must be a positive integer, got ${daysFlag}`);
+ const maxJev = flagValue(argv, "--max-jev") !== undefined ? Number(flagValue(argv, "--max-jev")) : DEFAULT_MAX_JEV;
+ if (!Number.isInteger(maxJev) || maxJev < 1) return fail(`--max-jev must be a positive integer, got ${flagValue(argv, "--max-jev")}`);
+ // dry-run by default (the sessions-verb house rule); --dry-run pins it.
+ const apply = hasFlag(argv, "--apply") && !hasFlag(argv, "--dry-run");
+ const dbPath = resolveSessionDb(argv, env);
+ if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`);
+ const vaultRoot = resolveVaultRoot(argv, env);
+ if (vaultRoot === undefined)
+ return fail("no personal vault mount found — pass --vault (or fix the mount stack; the scan never guesses a findings target)");
+
+ const now = Date.now();
+ const since = effectiveSinceMs(now, days, daysFlag !== undefined, readWatermarkMs(env));
+
+ // ── gather (READ-ONLY) + deterministic pre-filter (recall) ──────────────
+ let rows: Record[];
+ try {
+ rows = sqliteBatch(dbPath, "ro", [{ sql: CORRECTIONS_GATHER_SQL, params: [since] }]).results[0]!.rows;
+ } catch (e) {
+ return fail(e instanceof Error ? e.message : String(e));
+ }
+ const all: CorrectionCandidate[] = rows
+ .filter((r) => r.user_text !== null && r.user_text !== undefined && String(r.user_text).trim() !== "" && r.prev_assistant_text !== null)
+ .map((r) => ({
+ messageId: String(r.message_id),
+ sessionId: String(r.session_id),
+ sessionTitle: String(r.title),
+ timeCreated: Number(r.time_created),
+ userText: String(r.user_text),
+ precedingAssistantText: String(r.prev_assistant_text),
+ }))
+ .filter((c) => !isGreetingTitle(c.sessionTitle) && isCorrectionCandidate(c.userText));
+
+ // ── the middle layer (precision) — capped, fail-open ────────────────────
+ const judged = all.slice(0, maxJev);
+ const overflow = all.length - judged.length;
+ let jevStatus: JevPassStatus | "error" = "ran";
+ let verdicts: Record = {};
+ if (!jevDisabled(env)) {
+ try {
+ const pass = deps.jev !== undefined ? await deps.jev(judged) : await jevCorrections(judged, { env });
+ jevStatus = pass.status;
+ verdicts = pass.verdicts;
+ } catch (e) {
+ jevStatus = "error";
+ }
+ } else {
+ jevStatus = "disabled";
+ }
+
+ // ── the gate (admitted findings) + clustering ────────────────────────────
+ // Only a fully-run pass admits: verdicts from a partially-failed pass (key
+ // vanished mid-loop, circuit breaker) must not show up as would-be findings
+ // in the dry-run tuning output.
+ const admitted: AdmittedCorrection[] = [];
+ if (jevStatus === "ran") {
+ for (const c of judged) {
+ const v = verdicts[c.messageId];
+ if (v === undefined || v.error !== undefined || v.kind === undefined) continue;
+ if (!filingAdmits(v.kind, v.kind_confidence ?? 0, v.noul ?? 0)) continue;
+ admitted.push({ candidate: c, kind: v.kind as CorrectionKind, severity: severityOf(v.severity), kindConfidence: v.kind_confidence ?? 0, noul: v.noul ?? 0 });
+ }
+ }
+ const clusters = new Map();
+ for (const a of admitted) {
+ const key = clusterKey(a);
+ clusters.set(key, [...(clusters.get(key) ?? []), a]);
+ }
+ // What WOULD be filed — the dry-run's whole point (the tuning loop reads this).
+ const admittedDetail = [...clusters.values()].map((list) => ({
+ session_id: list[0]!.candidate.sessionId,
+ session_title: list[0]!.candidate.sessionTitle,
+ kind: list[0]!.kind,
+ severity: maxSeverity(list),
+ occurrences: list.length,
+ quotes: list.map((o) => o.candidate.userText.replace(/\s+/g, " ").trim().slice(0, 140)),
+ }));
+
+ // ── filing (apply only; dedup via UPDATE appends; watermark; digest; ping) ─
+ const passIso = new Date(now).toISOString();
+ const filed: { path: string; type: string; kind: string; severity: string; occurrences: number }[] = [];
+ const updated: string[] = [];
+ const findingsDir = findingsDirOf(vaultRoot);
+ if (apply && jevStatus === "ran") {
+ mkdirSync(findingsDir, { recursive: true });
+ let seq = 0;
+ for (const list of clusters.values()) {
+ const first = list[0]!;
+ const severity = maxSeverity(list);
+ const existing = findExistingScanFinding(findingsDir, first.candidate.sessionId, first.kind);
+ if (existing !== undefined) {
+ atomicWrite(existing, readFileSync(existing, "utf8").replace(/\n+$/, "") + renderUpdate(list, passIso));
+ updated.push(existing);
+ continue;
+ }
+ // Collision-safe: the seq is pass-scoped, so a triaged closed finding
+ // from an earlier pass can regenerate the same filename (same first-
+ // occurrence date, restarted seq, same title slug) — bump until free
+ // instead of silently overwriting triage state.
+ seq++;
+ let rendered = renderFinding(list, passIso, seq);
+ while (existsSync(join(findingsDir, rendered.filename))) rendered = renderFinding(list, passIso, ++seq);
+ const target = join(findingsDir, rendered.filename);
+ atomicWrite(target, rendered.content);
+ filed.push({ path: target, type: findingTypeOf(first.kind), kind: first.kind, severity, occurrences: list.length });
+ }
+ writeWatermarkMs(env, now);
+ }
+
+ // ── the digest (apply only) ──────────────────────────────────────────────
+ // "unverified" = candidates Jev never cleanly judged: the whole pass
+ // unavailable/disabled/errored, or the individual call failed (error
+ // verdict) — digest rows for the eye, never findings. The COUNT is true
+ // (uncapped); the digest renders the first DIGEST_UNVERIFIED_CAP + an
+ // overflow line.
+ const unverifiedList = judged
+ .filter((c) => jevStatus !== "ran" || (verdicts[c.messageId] !== undefined && verdicts[c.messageId]!.error !== undefined))
+ .map((c) => ({
+ sessionId: c.sessionId,
+ excerpt: c.userText.replace(/\s+/g, " ").trim().slice(0, 120),
+ }));
+ const digestFile = digestFileOf(vaultRoot);
+ let ping: { ok: boolean; error?: string; channel?: string } | undefined;
+ if (apply) {
+ const section = renderDigestSection({
+ passIso,
+ days,
+ sinceIso: new Date(since).toISOString(),
+ scanned: rows.length,
+ candidates: all.length,
+ jevStatus,
+ filed,
+ unverified: unverifiedList.slice(0, DIGEST_UNVERIFIED_CAP),
+ unverifiedOverflow: Math.max(0, unverifiedList.length - DIGEST_UNVERIFIED_CAP),
+ overflow,
+ pinged: undefined,
+ });
+ if (existsSync(digestFile)) {
+ atomicWrite(digestFile, readFileSync(digestFile, "utf8").replace(/\n+$/, "") + "\n\n" + section.replace(/\n+$/, "") + "\n");
+ } else {
+ atomicWrite(digestFile, DIGEST_HEADER + "\n" + section.replace(/\n+$/, "") + "\n");
+ }
+ // ── the P0/P1 ping (fail-open: a failed post is a warning, never a failed pass) ──
+ const channel = pingChannel(argv, env);
+ const pings = filed.filter((f) => pingWorthy(f.severity));
+ if (channel !== "" && pings.length > 0) {
+ const text = [`corrections-scan ${passIso}: ${pings.length} P0/P1 finding(s) filed`, "", ...pings.map((f) => `- ${f.severity} ${f.kind} (${f.occurrences}×): ${f.path}`)].join("\n");
+ const post = deps.post !== undefined ? deps.post(channel, text) : postViaAmicoSlack(channel, text);
+ ping = { ...post, channel };
+ atomicWrite(digestFile, readFileSync(digestFile, "utf8").replace(/\n+$/, "") + `\n- slack: ${post.ok ? `P0/P1 ping sent to ${channel}` : `ping FAILED (${post.error ?? "unknown"}) — findings are still filed`}\n`);
+ }
+ }
+
+ return {
+ json: {
+ verb: "corrections",
+ subcommand: "scan",
+ dry_run: !apply,
+ days,
+ since_ms: since,
+ since_iso: new Date(since).toISOString(),
+ scanned: rows.length,
+ candidates: all.length,
+ judged: judged.length,
+ overflow,
+ jev_status: jevStatus,
+ admitted: admitted.length,
+ clusters: clusters.size,
+ admitted_detail: admittedDetail,
+ filed,
+ updated,
+ unverified: unverifiedList.length,
+ watermark_path: apply ? correctionsWatermarkFile(env) : undefined,
+ digest_path: apply ? digestFile : undefined,
+ ping,
+ note: apply ? undefined : "dry-run: nothing written — pass --apply to file findings, write the watermark, and update the digest",
+ },
+ code: 0,
+ };
+}
+
+/** The `corrections` verb body: route on the subcommand. Backs the CLI
+ * (amico.ts — SPINE_VERBS dispatch). It is NOT published as an agent MCP tool
+ * by default (`mcpDefault: false` in verbs.ts — a preview, spend-bearing
+ * classifier pass); opt in with AMICO_MCP_EXPOSE=corrections. */
+export async function correctionsVerb(argv: string[]): Promise {
+ const sub = argv[0];
+ if (sub === "scan") return correctionsScan(argv.slice(1), process.env);
+ return {
+ json: {
+ verb: "corrections",
+ error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`,
+ usage: USAGE,
+ },
+ code: 64,
+ };
+}
diff --git a/packages/amico-run/src/fleet_verb.ts b/packages/amico-run/src/fleet_verb.ts
index 7d0d59c8f..fb1e603c7 100644
--- a/packages/amico-run/src/fleet_verb.ts
+++ b/packages/amico-run/src/fleet_verb.ts
@@ -49,6 +49,7 @@
import { FRONTIER_MODELS, ladderRungs } from "./ledger_dispatch.js";
import { fleetDigest } from "./fleet_digest.js";
import { shardWatch } from "./shard_watch.js";
+import { shardRehome } from "./shard_rehome.js";
// The fleet-authority projection status (#1068, rearchitect P3b-1): the same
// `amico fleet status` verb, one more read path — `--projection` routes to the
// entitlement-gated publisher invocation + the shared reader, while the
@@ -594,7 +595,8 @@ const USAGE =
"amico fleet enroll --as-server [--host H --port N --ssh-alias A --transport-hint T] | " +
"amico fleet enroll --join-token | " +
"amico fleet digest [--post ] [--machines a,b] [--jobs-line \"\"] [--dry-run] [--root D] | " +
- "amico fleet shard-watch [--clients a,b] [--port