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

] [--alert-min ] [--db ] [--post ] [--dry-run]"; + "amico fleet shard-watch [--clients a,b] [--port

] [--alert-min ] [--db ] [--post ] [--dry-run] | " + + "amico fleet shard-rehome --client-home [--db ] [--apply]"; /** Optional injection surface for the fleet verb's sub-verbs — the projection * status's hermetic seam (publisher subprocess, entitlement file, checkout @@ -625,6 +627,7 @@ export function fleetVerb(argv: string[], deps: FleetVerbDeps = {}): VerbResult // NOTE: `enroll` is async (real HTTP) and is dispatched at the registry seam // (verbs.ts `run`), NOT here — so this synchronous router stays synchronous // and its pinned VerbResult contract (and callers) are untouched. + if (sub === "shard-rehome") return shardRehome(rest); return { json: { verb: "fleet", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, usage: USAGE }, code: 64, diff --git a/packages/amico-run/src/mcp_serve.ts b/packages/amico-run/src/mcp_serve.ts index 215c56bc1..93e094329 100644 --- a/packages/amico-run/src/mcp_serve.ts +++ b/packages/amico-run/src/mcp_serve.ts @@ -31,13 +31,22 @@ export function verbToMcpTool(v: Verb): McpToolDescriptor { }; } -export function listMcpTools(): McpToolDescriptor[] { - return SPINE_VERBS.map(verbToMcpTool); +/** Is this verb published on the MCP (agent) surface? Verbs default IN; a + * verb marked `mcpDefault: false` is opt-out unless named in + * AMICO_MCP_EXPOSE (comma-separated verb names). */ +export function isMcpExposed(v: Verb, env: NodeJS.ProcessEnv = process.env): boolean { + if (v.mcpDefault !== false) return true; + const optIn = (env.AMICO_MCP_EXPOSE ?? "").split(",").map((s) => s.trim()).filter(Boolean); + return optIn.includes(v.name); +} + +export function listMcpTools(env: NodeJS.ProcessEnv = process.env): McpToolDescriptor[] { + return SPINE_VERBS.filter((v) => isMcpExposed(v, env)).map(verbToMcpTool); } /** tools/call handler — dispatches to the same Verb.run the CLI uses. */ -export async function callMcpTool(name: string, argv: string[]): Promise { - const verb = SPINE_VERBS.find((v) => `amico_${v.name}` === name); +export async function callMcpTool(name: string, argv: string[], env: NodeJS.ProcessEnv = process.env): Promise { + const verb = SPINE_VERBS.find((v) => `amico_${v.name}` === name && isMcpExposed(v, env)); if (!verb) throw new Error(`unknown tool ${name}`); if (argv[0] !== undefined && verb.cliOnlySubcommands?.includes(argv[0])) return { verb: verb.name, subcommand: argv[0], ok: false, error: `\`amico ${verb.name} ${argv[0]}\` is CLI-only — not exposed through MCP` }; diff --git a/packages/amico-run/src/note.ts b/packages/amico-run/src/note.ts index d2d7ac2f3..f1a595616 100644 --- a/packages/amico-run/src/note.ts +++ b/packages/amico-run/src/note.ts @@ -362,8 +362,14 @@ export interface RoutedNoteFields { stamp: string; // YYYYMMDD-HHMMSS route_intent?: string; // stamped only on a personal fallback session_id?: string | null; // routed notes are agent-agnostic → null + /** Extra frontmatter scalars (`--meta key=value`), e.g. `status`/`visibility` a + * spec needs to clear its schema. Emitted after `tags`, before `route_intent`. */ + meta?: Record; } +/** Frontmatter keys a caller may NOT set through `--meta` (the renderer owns them). */ +export const RESERVED_META_KEYS = ["type", "date", "session_id", "tags", "route_intent"] as const; + /** Render a routed generic note: minimal frontmatter (`type`, `date`, * `session_id: null`, `tags: []`, and `route_intent` only when set) + an H1 * title and the supplied body. Deterministic — stamp/date/intent come from the @@ -376,9 +382,64 @@ export function renderRoutedNote(f: RoutedNoteFields): string { `session_id: ${f.session_id ? `"${f.session_id}"` : "null"}`, `tags: [${f.type}]`, ]; + for (const [k, v] of Object.entries(f.meta ?? {})) fm.push(`${k}: ${v}`); if (f.route_intent) fm.push(`route_intent: ${f.route_intent}`); fm.push("---"); const body = ["", `# ${f.title}`, "", f.body.trim(), ""].join("\n"); return fm.join("\n") + "\n" + body + "\n"; } + +// ── schema check before a team write (#1863) ───────────────────────────────────── +// Required frontmatter per note type — mirrors the `amico-schema-check` skill's +// table (owned by `amico-vault`; change it there and mirror here). Only the types +// the routed writer can produce are listed. +export const ROUTED_REQUIRED_FIELDS: Record = { + spec: ["type", "date", "session_id", "status", "tags", "visibility"], + plan: ["type", "date", "session_id", "status", "spec", "tags", "visibility"], + insight: ["type", "date", "session_id", "evidence", "confidence", "tags"], + method: ["type", "name", "date", "session_id", "applicability", "tags"], + note: ["type", "date", "session_id", "tags"], + hopper: ["type", "date", "status", "tags"], +}; + +/** The top-level `key: value` pairs of a note's leading frontmatter block (raw value + * text, quotes kept). Empty map when there is no frontmatter. */ +export function frontmatterMap(text: string): Map { + const out = new Map(); + const lines = text.split("\n"); + if (lines[0] !== "---") return out; + for (let i = 1; i < lines.length && lines[i] !== "---"; i++) { + const m = lines[i].match(/^([A-Za-z_][A-Za-z0-9_]*):(.*)$/); + if (m) out.set(m[1], m[2].trim()); + } + return out; +} + +/** Schema errors for a rendered routed note (empty = valid). A required key that is + * absent or has a blank value is an error; so is a `type:` that disagrees with the + * declared type. Types without a schema row here are not checked. */ +export function validateRoutedNote(type: string, text: string): string[] { + const required = ROUTED_REQUIRED_FIELDS[type]; + if (!required) return []; + const fm = frontmatterMap(text); + if (fm.size === 0) return ["no frontmatter"]; + const errors: string[] = []; + const missing = required.filter((k) => !fm.has(k) || fm.get(k) === ""); + if (missing.length) errors.push(`missing required field(s): ${missing.join(", ")}`); + const declared = fm.get("type")?.replace(/^["']|["']$/g, ""); + if (declared !== undefined && declared !== type) errors.push(`type "${declared}" does not match "${type}"`); + return errors; +} + +/** Attach a validation error to a note that is being quarantined: a single + * `quarantine_error:` frontmatter line (JSON-quoted, so it is one YAML scalar). The + * rest of the note is untouched. */ +export function attachQuarantineError(text: string, errors: readonly string[]): string { + const lines = text.split("\n"); + if (lines[0] !== "---") return `---\nquarantine_error: ${JSON.stringify(errors.join("; "))}\n---\n${text}`; + const close = lines.indexOf("---", 1); + if (close === -1) return text; + lines.splice(close, 0, `quarantine_error: ${JSON.stringify(errors.join("; "))}`); + return lines.join("\n"); +} diff --git a/packages/amico-run/src/note_verb.ts b/packages/amico-run/src/note_verb.ts index a72464cdd..23dcdc7dd 100644 --- a/packages/amico-run/src/note_verb.ts +++ b/packages/amico-run/src/note_verb.ts @@ -21,11 +21,18 @@ // amico note route --type --title // (--body | --body-file ) [--intent ] // [--stamp YYYYMMDD-HHMMSS] [--commit] [--dry-run] +// [--role --session --source ] [--meta key=value ...] // → the routed GENERIC writer (plan Task 8): pick the first WRITABLE mount of // the intent kind, else fall back to the personal mount and stamp // `route_intent`. `experiment` is NOT a valid --type (that is `note write`'s // job). `--stamp` is injectable (clock injected at this verb layer, not the // pure core). NEW subcommand — `note write`/`bump-best` are untouched. +// TEAM mounts (#1863): the write is attributed (--role/--session/--source +// are all mandatory, refused otherwise), the mount must be a git repo, the +// note is schema-checked before it lands (a failure goes to the mount's +// quarantine/ with `quarantine_error` attached, exit 65), and the result is +// ONE commit whose message carries Agent-Role/Session/Source trailers, then +// `git push origin HEAD`. Personal mounts are unchanged. // // FLAG NAMES (S31 guard): the physics-knob double-dash flags (gate/pulse/system) // are banned in src/; the gate discriminator is `--kind` (mapping onto the note @@ -36,6 +43,8 @@ import { join } from "node:path"; import { parse as parseToml } from "smol-toml"; import { ROUTE_FOLDERS, + RESERVED_META_KEYS, + attachQuarantineError, bumpBestGatesInText, experimentId, isRoutableType, @@ -43,6 +52,7 @@ import { renderRoutedNote, routeNote, routedNoteBasename, + validateRoutedNote, type BestGate, type ExperimentFields, } from "./note.js"; @@ -253,6 +263,83 @@ function gitCommit(mountPath: string, file: string, message: string): { committe } } +// ── team-mount attribution (#1863) ───────────────────────────────────────────── +// A write to a `team` mount is attributed: the commit message names the agent role, +// the session, and the source (the brief/run/digest that caused it), so attribution +// is machine-checkable from the commit alone. Personal mounts keep today's behavior. + +/** Every value of a repeatable flag (`--meta a=1 --meta b=2`), in argv order. */ +function flagValues(argv: string[], name: string): string[] { + const out: string[] = []; + for (let i = 0; i + 1 < argv.length; i++) if (argv[i] === name) out.push(argv[i + 1]); + return out; +} + +interface Attribution { + role: string; + session: string; + source: string; +} + +/** The trimmed `--role/--session/--source` triple, plus the flags that are missing. */ +function readAttribution(argv: string[]): { attr: Partial; missing: string[] } { + const attr: Partial = { + role: flagValue(argv, "--role")?.trim() || undefined, + session: flagValue(argv, "--session")?.trim() || undefined, + source: flagValue(argv, "--source")?.trim() || undefined, + }; + const missing = (["role", "session", "source"] as const).filter((k) => !attr[k]).map((k) => `--${k}`); + return { attr, missing }; +} + +/** `--meta key=value` pairs → frontmatter scalars. Rejects malformed pairs, reserved + * keys (the renderer owns them), and multi-line values. */ +function parseMeta(pairs: string[]): { meta: Record } | { error: string } { + const meta: Record = {}; + for (const p of pairs) { + const eq = p.indexOf("="); + const key = eq > 0 ? p.slice(0, eq).trim() : ""; + const value = eq > 0 ? p.slice(eq + 1).trim() : ""; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || value === "" || /[\r\n]/.test(value)) { + return { error: `--meta must be key=value with a simple key and a one-line value (got "${p}")` }; + } + if ((RESERVED_META_KEYS as readonly string[]).includes(key)) { + return { error: `--meta cannot set the reserved frontmatter key "${key}"` }; + } + meta[key] = value; + } + return { meta }; +} + +/** Commit ONE path with the attribution trailers. `--` scopes the commit to that + * path, so anything else staged in the mount is left out of this write. */ +function gitCommitAttributed( + mountPath: string, + file: string, + subject: string, + attr: Attribution, +): { committed: boolean; warning?: string } { + const message = [subject, "", `Agent-Role: ${attr.role}`, `Session: ${attr.session}`, `Source: ${attr.source}`].join("\n"); + try { + execFileSync("git", ["-C", mountPath, "add", "--", file], { stdio: ["ignore", "ignore", "pipe"] }); + execFileSync("git", ["-C", mountPath, "commit", "-q", "-m", message, "--", file], { stdio: ["ignore", "ignore", "pipe"] }); + return { committed: true }; + } catch (e) { + return { committed: false, warning: `git commit failed: ${e instanceof Error ? e.message : String(e)}` }; + } +} + +/** `git push origin HEAD` for the mount. A failure is reported, never thrown: the + * commit is already on disk and revertible, so the caller decides the exit code. */ +function gitPush(mountPath: string): { pushed: boolean; warning?: string } { + try { + execFileSync("git", ["-C", mountPath, "push", "-q", "origin", "HEAD"], { stdio: ["ignore", "ignore", "pipe"] }); + return { pushed: true }; + } catch (e) { + return { pushed: false, warning: `git push failed: ${e instanceof Error ? e.message : String(e)}` }; + } +} + export function noteRoute(argv: string[]): VerbResult { const fail = (error: string, extra: Record = {}): VerbResult => ({ json: { verb: "note", subcommand: "route", error, ...extra }, @@ -296,9 +383,40 @@ export function noteRoute(argv: string[]): VerbResult { if ("error" in decision) return fail(decision.error); const { mount, routeIntent } = decision; - const content = renderRoutedNote({ type, title, body, stamp, route_intent: routeIntent, session_id: null }); + // A team write is governed: attribution is mandatory and the mount must be a git + // repo, so every write is a revertible, attributed commit. Refused BEFORE anything + // is written. Personal (and any non-team) mounts skip this block entirely. + const governed = mount.kind === "team"; + const { attr, missing } = readAttribution(argv); + if (governed) { + if (missing.length) { + return fail( + `team write refused: attribution is mandatory — missing ${missing.join(", ")} (pass --role --session --source )`, + { mount: mount.name }, + ); + } + if (!existsSync(join(mount.path, ".git"))) { + return fail(`team write refused: mount "${mount.name}" is not a git repository — every team write must be a revertible git commit`, { + mount: mount.name, + }); + } + } + + const parsedMeta = parseMeta(flagValues(argv, "--meta")); + if ("error" in parsedMeta) return fail(parsedMeta.error); + + const basename = routedNoteBasename(type, stamp, title); + const content = renderRoutedNote({ + type, + title, + body, + stamp, + route_intent: routeIntent, + session_id: governed ? attr.session! : null, + meta: parsedMeta.meta, + }); const dir = join(mount.path, folder); - const file = join(dir, `${routedNoteBasename(type, stamp, title)}.md`); + const file = join(dir, `${basename}.md`); const common = { verb: "note", @@ -310,6 +428,31 @@ export function noteRoute(argv: string[]): VerbResult { path: file, }; + // Schema check before a team write lands. A failure is quarantined, never written + // to its target path. Personal writes are not schema-gated (today's behavior). + const errors = governed ? validateRoutedNote(type, content) : []; + if (errors.length) { + const quarantineDir = join(mount.path, "quarantine"); + const quarantinePath = join(quarantineDir, `${basename}.md`); + const { path: _targetPath, ...rest } = common; + const quarantined = { ...rest, written: false, quarantined: true, errors, quarantine_path: quarantinePath }; + if (argv.includes("--dry-run")) { + return { json: { ...quarantined, dry_run: true, content }, code: 0 }; + } + try { + mkdirSync(quarantineDir, { recursive: true }); + writeFileSync(quarantinePath, attachQuarantineError(content, errors)); + } catch (e) { + return fail(`failed to quarantine note: ${e instanceof Error ? e.message : String(e)}`); + } + const commit = gitCommitAttributed(mount.path, quarantinePath, `quarantine: ${basename}`, attr as Attribution); + const push = commit.committed ? gitPush(mount.path) : { pushed: false }; + return { + json: { ...quarantined, commit, ...push }, + code: 65, // reported: the note did NOT land at its target path + }; + } + if (argv.includes("--dry-run")) { return { json: { ...common, written: false, dry_run: true, content }, code: 0 }; } @@ -322,8 +465,18 @@ export function noteRoute(argv: string[]): VerbResult { } const result: Record = { ...common, written: true }; + if (governed) { + const commit = gitCommitAttributed(mount.path, file, `note: add ${basename}`, attr as Attribution); + result.commit = commit; + result.pushed = commit.committed ? gitPush(mount.path).pushed : false; + if (!commit.committed || result.pushed === false) { + result.warning = "written but not fully recorded: see commit/push status"; + return { json: result, code: 1 }; + } + return { json: result, code: 0 }; + } if (argv.includes("--commit")) { - result.commit = gitCommit(mount.path, file, `note: add ${routedNoteBasename(type, stamp, title)}`); + result.commit = gitCommit(mount.path, file, `note: add ${basename}`); } return { json: result, code: 0 }; } @@ -342,7 +495,7 @@ export function noteVerb(argv: string[]): VerbResult { verb: "note", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, usage: - "amico note write --platform

--kind --fidelity | amico note bump-best --platform

--kind --fidelity [--source ] | amico note route --type --title (--body | --body-file ) [--intent ] [--stamp ] [--commit]", + "amico note write --platform

--kind --fidelity | amico note bump-best --platform

--kind --fidelity [--source ] | amico note route --type --title (--body | --body-file ) [--intent ] [--stamp ] [--commit] [--role --session --source ] [--meta k=v]", }, code: 64, }; diff --git a/packages/amico-run/src/shard_rehome.ts b/packages/amico-run/src/shard_rehome.ts new file mode 100644 index 000000000..fd577807e --- /dev/null +++ b/packages/amico-run/src/shard_rehome.ts @@ -0,0 +1,199 @@ +// shard_rehome.ts — `amico fleet shard-rehome` (#1658): the tested directory +// re-home for client shards merged into the canonical DB. +// +// amico fleet shard-rehome --client-home [--db ] [--apply] +// +// Shard merges are #1302's by-hand flow (shard-watch is detection-only, #1306 — +// "the merge, a kill, any remediation: out of the vocabulary"), and the hand-run +// re-home of the 2026-10-01 macbook shard doubled path segments on two different +// home prefixes, producing directories that exist nowhere on the hub — the +// sessions merged fine but turned invisible to every panel listing, which +// filters by project directory. This verb is the piece of that by-hand flow that +// must never be hand-rolled SQL again: +// +// 1. reads every canonical session whose directory lives under the client home; +// 2. maps each with ONE explicit prefix substitution (client home → hub home) — +// doubling is structurally impossible: no repeated application, no string +// splicing, the candidate is exactly hubHome + from.slice(clientHome.length); +// 3. VALIDATES the candidate exists on the hub filesystem before writing — +// a missing target falls back to the shard's original directory (an +// invisible-but-truthful row beats an invisible-and-wrong one); +// 4. dry-run by default (the plan, one row per session); --apply performs the +// per-id UPDATEs and re-reads the touched rows to verify what landed. +// +// CONSTRAINTS (the issue's invariants, enforced structurally): +// - The mapping is a pure function of (rows, clientHome, hubHome, exists) — +// table-tested below; the verb's only impure edges (the DB read/write via +// the python3 bridge, the filesystem existence check, the env) are +// injectable, mirroring shard_watch.ts. +// - Rows not under the client home are never touched, never reported as +// errors — the verb is scoped to the merge's own residue. +// - A failed apply verification is a nonzero exit, never a soft warning. +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolveSessionDb } from "./sessions_verb.js"; +import { sqliteBatch, type BridgeStatement, type BridgeResult } from "./sqlite_bridge.js"; +import type { VerbResult } from "./verbs.js"; + +// ── the pure re-home planner (table-tested; no fs, no clock, no spawn) ────── + +export interface RehomeRow { + id: string; + directory: string; +} + +export interface RehomeDecision { + id: string; + from: string; + to: string; + action: "rehome" | "keep"; + reason: string; +} + +export interface RehomePlanOptions { + clientHome: string; + hubHome: string; + exists: (directory: string) => boolean; +} + +function under(path: string, home: string): boolean { + return path === home || path.startsWith(home + "/"); +} + +export function planRehome(rows: RehomeRow[], opts: RehomePlanOptions): RehomeDecision[] { + const { clientHome, hubHome, exists } = opts; + return rows.map((row) => { + const from = row.directory; + if (under(from, hubHome)) { + return { id: row.id, from, to: from, action: "keep", reason: "already hub-side" }; + } + if (!under(from, clientHome)) { + return { id: row.id, from, to: from, action: "keep", reason: "not under the client home — untouched" }; + } + const candidate = hubHome + from.slice(clientHome.length); + if (candidate === from) { + return { id: row.id, from, to: from, action: "keep", reason: "client home equals hub home — nothing to map" }; + } + if (!exists(candidate)) { + return { + id: row.id, + from, + to: from, + action: "keep", + reason: `target ${candidate} does not exist on the hub — keeping the shard's original directory`, + }; + } + return { id: row.id, from, to: candidate, action: "rehome", reason: "client home → hub home (single prefix substitution, target validated)" }; + }); +} + +// ── the verb ───────────────────────────────────────────────────────────────── + +export interface ShardRehomeDeps { + env?: NodeJS.ProcessEnv; + hubHome?: string; + exists?: (directory: string) => boolean; + batch?: (dbPath: string, mode: "ro" | "rw", statements: BridgeStatement[], env: NodeJS.ProcessEnv) => BridgeResult; +} + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i !== -1 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +function rehomeFail(error: string, extra: Record = {}): VerbResult { + return { json: { verb: "fleet", subcommand: "shard-rehome", ok: false, errors: [error], ...extra }, code: 64 }; +} + +export function shardRehome(argv: string[], deps: ShardRehomeDeps = {}): VerbResult { + const env = deps.env ?? process.env; + const hubHome = deps.hubHome ?? homedir(); + const exists = deps.exists ?? ((d: string) => existsSync(d)); + const batch = deps.batch ?? sqliteBatch; + const apply = argv.includes("--apply"); + const ts = new Date().toISOString(); + + const clientHome = flagValue(argv, "--client-home"); + if (clientHome === undefined || clientHome.trim() === "") { + return rehomeFail("--client-home is required — the re-home maps that prefix onto this machine's home; an unscoped re-home must not run"); + } + if (!clientHome.startsWith("/")) { + return rehomeFail(`--client-home must be an absolute path (got "${clientHome}")`); + } + const dbPath = flagValue(argv, "--db") ?? resolveSessionDb(argv, env); + + // read: every session whose directory lives under the client home — the merge's + // residue, and nothing else. + let rows: RehomeRow[]; + try { + const read = batch(dbPath, "ro", [ + { sql: "SELECT id, directory FROM session WHERE directory LIKE ?", params: [clientHome + "%"] }, + ], env); + rows = (read.results[0]?.rows ?? []).map((r) => ({ + id: String(r.id), + directory: String(r.directory), + })); + } catch (e) { + return rehomeFail(`canonical DB unreadable: ${(e as Error).message}`, { canonical_db: dbPath }); + } + + const plan = planRehome(rows, { clientHome, hubHome, exists }); + const rehomes = plan.filter((d) => d.action === "rehome"); + const keeps = plan.filter((d) => d.action === "keep"); + + const json: Record = { + receipt_version: 1, + ts, + verb: "fleet", + subcommand: "shard-rehome", + kind: "shard-rehome", + ok: true, + mode: apply ? "apply" : "dry-run", + canonical_db: dbPath, + client_home: clientHome, + hub_home: hubHome, + scanned: rows.length, + rehome_count: rehomes.length, + kept_count: keeps.length, + plan, + }; + if (!apply) { + json.would_apply = rehomes.length; + return { json, code: 0 }; + } + + // apply: per-id UPDATEs (never a string REPLACE over the table — precision is + // the point), then re-read the touched ids and verify every one landed. + try { + if (rehomes.length > 0) { + const write = batch(dbPath, "rw", rehomes.map((d) => ({ + sql: "UPDATE session SET directory = ? WHERE id = ?", + params: [d.to, d.id], + })), env); + const changed = write.results.reduce((n, r) => n + (r.changes ?? 0), 0); + if (changed !== rehomes.length) { + return rehomeFail(`apply touched ${changed} rows but planned ${rehomes.length} — rolling the report back, the DB commit stands; re-run the dry-run to see the remaining state`, { canonical_db: dbPath }); + } + } + if (rehomes.length > 0) { + const placeholders = rehomes.map(() => "?").join(","); + const verify = batch(dbPath, "ro", [ + { sql: `SELECT id, directory FROM session WHERE id IN (${placeholders})`, params: rehomes.map((d) => d.id) }, + ], env); + const landed = new Map((verify.results[0]?.rows ?? []).map((r) => [String(r.id), String(r.directory)])); + const mismatches = rehomes + .filter((d) => landed.get(d.id) !== d.to) + .map((d) => `${d.id}: expected ${d.to}, found ${landed.get(d.id) ?? "row missing"}`); + if (mismatches.length > 0) { + return rehomeFail(`post-apply verification failed for ${mismatches.length} row(s)`, { canonical_db: dbPath, mismatches }); + } + json.verified = true; + } else { + json.verified = true; // nothing to verify — an honest all-keep apply + } + } catch (e) { + return rehomeFail(`apply failed: ${(e as Error).message}`, { canonical_db: dbPath }); + } + + return { json, code: 0 }; +} diff --git a/packages/amico-run/src/spec_slices.ts b/packages/amico-run/src/spec_slices.ts index 8a0043a84..17d69a1be 100644 --- a/packages/amico-run/src/spec_slices.ts +++ b/packages/amico-run/src/spec_slices.ts @@ -13,6 +13,10 @@ // spec id) and — when a PRD is given — the PRD's sub-issue list, which is strongly consistent // where search is eventually consistent (a quick re-run would otherwise duplicate). // +// ADOPTION (#1949 §5): a row whose step text's first issue reference is `(#N)`, a leading `#N`, +// `repo#N` or `owner/repo#N` adopts that existing issue instead of filing one — the marker is +// appended to its body (once), and it is linked and wired like a filed row. See parseAdoption. +// // GITHUB rides `gh api` through an injected layer (`SlicesGh`) over an injected runner // (`GhApiRunner`) — the handoff_verb exec-seam precedent. The real runner spawns `gh` from // PATH, which in an agent session is the amico-gh shim (gh_shim.ts), so writes go out under @@ -52,11 +56,42 @@ export interface SliceStep extends CompiledStep { tier: number; title: string; repo: string; + /** Set when the step text's first issue reference names an existing issue to ADOPT + * (`parseAdoption`): the row files no new issue, it marks and links that one. */ + adopts?: IssueAddr; } const EXPECTED_HEADER = ["#", "tier", "step", "repo", "task_type", "after"]; const NONE = new Set(["—", "–", "-", ""]); +// ── adoption (#1949 §5) ────────────────────────────────────────────────────── +// A row whose step text's FIRST issue reference is one of these ADOPTS that issue: +// `(#N)` — exactly a parenthesized number; the issue is in the row's repo +// `#N` leading — the step text BEGINS with it (the step names the issue); row's repo +// `repo#N` — harmoniqs/#N (no space before `#`) +// `owner/repo#N` — verbatim +// Any other first reference — a bare mid-text `#N`, `(#N more words)`, a spaced `repo #N` — +// is prose about context, and the row files a new issue as before. Only the FIRST reference is +// consulted, so a non-adopting first reference blocks a later adopting one. References inside +// `code spans` are ignored. +const ISSUE_REF_RE = /(? " ".repeat(m.length)); + ISSUE_REF_RE.lastIndex = 0; + const m = ISSUE_REF_RE.exec(scan); + if (!m) return undefined; + const [whole, owner, repo, num] = m; + const number = Number(num); + if (owner && repo) return { repo: `${owner}/${repo}`, number }; + if (repo) return { repo: `${SLICES_ORG}/${repo}`, number }; + const end = m.index + whole.length; + const parenthesized = scan[m.index - 1] === "(" && scan[end] === ")"; + if (parenthesized || m.index === 0) return { repo: `${SLICES_ORG}/${rowRepo}`, number }; + return undefined; +} + /** Split a markdown table line on UNESCAPED pipes, dropping the outer empties. */ function cells(line: string): string[] { const out: string[] = []; @@ -124,6 +159,7 @@ export function parsePlanTable(markdown: string): SliceStep[] { for (const n of needs) if (!/^\d+$/.test(n)) throw new PlanTableError("bad-row", `${where}: after must be row numbers or —, got "${afterRaw}"`); } + const adopts = parseAdoption(title, repo); steps.push({ id: String(row), model: "", @@ -133,6 +169,7 @@ export function parsePlanTable(markdown: string): SliceStep[] { tier: Number(tierRaw), title, repo, + ...(adopts ? { adopts } : {}), }); } if (steps.length === 0) throw new PlanTableError("missing-table", "the Plan table has no rows"); @@ -184,6 +221,25 @@ export function parseProvenance(body: string | undefined): { spec_id: string; ro return m ? { spec_id: m[1], row: Number(m[2]), design_hash: m[3] } : undefined; } +/** Does `body` carry THIS spec's marker for THIS row (any design hash)? An adopted issue may + * carry other specs' markers too, so every marker is checked, not just the first. */ +export function hasProvenance(body: string | undefined, specId: string, row: number): boolean { + const all = new RegExp(MARKER_RE.source, "g"); + for (const m of (body ?? "").matchAll(all)) if (m[1] === specId && Number(m[2]) === row) return true; + return false; +} + +/** The adopted issue's body with the provenance line + marker appended; the original is kept verbatim. */ +export function adoptedBody(body: string, step: SliceStep, specId: string, designHash: string): string { + return [ + body.replace(/\s+$/, ""), + "", + `Provenance: adopted by \`amico spec slices\` as \`${specId}\` row ${step.row} (tier ${step.tier}), design hash \`${designHash}\`.`, + provenanceMarker(specId, step.row, designHash), + "", + ].join("\n"); +} + export function issueBody(step: SliceStep, specId: string, designHash: string, prd?: IssueAddr): string { const after = step.needs?.length ? step.needs.map((n) => `row ${n}`).join(", ") : "none"; return [ @@ -208,12 +264,17 @@ export interface IssueAddr { export interface GhIssue extends IssueAddr { id: number; // the numeric REST id — what sub_issue_id / issue_id take, NOT the number body?: string; + state?: "open" | "closed"; } export interface SlicesGh { /** Issues in `repo` whose body may carry this spec's marker (the caller re-checks the marker). */ findFiled(repo: string, specId: string): GhIssue[]; createIssue(repo: string, title: string, body: string): GhIssue; + /** One issue by address (adoption reads the body and state before it writes). */ + getIssue(addr: IssueAddr): GhIssue; + /** Replace an issue's body (adoption appends the provenance marker, nothing else). */ + editIssueBody(addr: IssueAddr, body: string): void; listSubIssues(parent: IssueAddr): GhIssue[]; addSubIssue(parent: IssueAddr, childId: number): void; listBlockedBy(issue: IssueAddr): GhIssue[]; @@ -258,6 +319,13 @@ export function ghApiSlices(runner: GhApiRunner = defaultGhApiRunner): SlicesGh const r = call(["-X", "POST", `repos/${repo}/issues`, "-f", `title=${title}`, "-f", `body=${body}`]) as RawIssue; return { id: r.id, number: r.number, repo, body: r.body ?? body }; }, + getIssue(a) { + const r = call([`repos/${a.repo}/issues/${a.number}`]) as RawIssue & { state?: string }; + return { id: r.id, number: r.number, repo: a.repo, body: r.body ?? "", state: r.state === "closed" ? "closed" : "open" }; + }, + editIssueBody(a, body) { + call(["-X", "PATCH", `repos/${a.repo}/issues/${a.number}`, "-f", `body=${body}`]); + }, listSubIssues: (p) => list(`repos/${p.repo}/issues/${p.number}/sub_issues`), addSubIssue(p, childId) { call(["-X", "POST", `repos/${p.repo}/issues/${p.number}/sub_issues`, "-F", `sub_issue_id=${childId}`]); @@ -278,6 +346,8 @@ export interface SlicesPlanRow { title: string; task_type: string; after: number[]; + /** `owner/repo#n` when the row adopts an existing issue instead of filing one. */ + adopts?: string; } export interface SlicesEdge { row: number; // blocked @@ -292,14 +362,19 @@ export function planView(steps: SliceStep[]): { rows: SlicesPlanRow[]; edges: Sl title: s.title, task_type: s.task_type, after: (s.needs ?? []).map(Number), + ...(s.adopts ? { adopts: `${s.adopts.repo}#${s.adopts.number}` } : {}), })); const edges = rows.flatMap((r) => r.after.map((b) => ({ row: r.row, blocked_by: b }))); return { rows, edges, repos: [...new Set(rows.map((r) => r.repo))] }; } export interface SlicesRowReport extends SlicesPlanRow { - status: "created" | "existing"; + status: "created" | "existing" | "adopted"; issue?: IssueAddr; + /** The adopted issue's state — a closed issue is still adopted (see fileSlices). */ + state?: "open" | "closed"; + /** An issue previously FILED for this row (before it adopted) — reported, never touched. */ + superseded?: IssueAddr; } export interface SlicesReport { rows: SlicesRowReport[]; @@ -307,6 +382,12 @@ export interface SlicesReport { created_issues: number; created_edges: number; created_sub_issues: number; + adopted_issues: number; + edited_bodies: number; +} + +export function emptySlicesReport(): SlicesReport { + return { rows: [], edges: [], created_issues: 0, created_edges: 0, created_sub_issues: 0, adopted_issues: 0, edited_bodies: 0 }; } export interface FileSlicesOptions { @@ -327,7 +408,7 @@ export function fileSlices(steps: SliceStep[], opts: FileSlicesOptions): SlicesR for (const s of steps) for (const n of s.needs ?? []) if (!ids.has(n)) throw new PlanTableError("dangling-after", `row ${s.row}: after names row ${n}, which is not being filed`); - const report: SlicesReport = opts.progress ?? { rows: [], edges: [], created_issues: 0, created_edges: 0, created_sub_issues: 0 }; + const report: SlicesReport = opts.progress ?? emptySlicesReport(); // Existing issues, by row: the PRD's sub-issues (consistent) ∪ each repo's search (eventual). // Keyed on (row, repo): if a row's repo changes between revisions, the old issue is left alone @@ -342,10 +423,31 @@ export function fileSlices(steps: SliceStep[], opts: FileSlicesOptions): SlicesR prdChildren.forEach(consider); for (const repo of view.repos) gh.findFiled(repo, specId).forEach(consider); + // ADOPTION (#1949 §5): a row whose step text names an existing issue (`parseAdoption`) files + // nothing — the named issue IS the slice. Its body gains the provenance marker unless this + // spec+row marker is already there (any hash — idempotent), and it is linked and wired like a + // filed one. A CLOSED adopted issue is still adopted, marked, linked and wired: the plan's DAG + // stays whole, and a closed blocker is a satisfied edge. An issue FILED for the row before it + // adopted is reported as `superseded` and left alone (closing a public issue is a human call). const byRow = new Map(); for (const [k, s] of steps.entries()) { const r = view.rows[k]; - let issue = existing.get(`${s.row}@${r.repo}`); + const prior = existing.get(`${s.row}@${r.repo}`); + if (s.adopts) { + const issue = gh.getIssue(s.adopts); + if (!hasProvenance(issue.body, specId, s.row)) { + const body = adoptedBody(issue.body ?? "", s, specId, designHash); + gh.editIssueBody(s.adopts, body); + issue.body = body; + report.edited_bodies++; + } + report.adopted_issues++; + byRow.set(s.row, issue); + const superseded = prior && prior.id !== issue.id ? { superseded: { repo: prior.repo, number: prior.number } } : {}; + report.rows.push({ ...r, status: "adopted", issue: { repo: issue.repo, number: issue.number }, state: issue.state ?? "open", ...superseded }); + continue; + } + let issue = prior; let status: "created" | "existing" = "existing"; if (!issue) { issue = gh.createIssue(r.repo, s.title, issueBody(s, specId, designHash, prd)); diff --git a/packages/amico-run/src/spec_verb.ts b/packages/amico-run/src/spec_verb.ts index a7c7ec916..22fd08268 100644 --- a/packages/amico-run/src/spec_verb.ts +++ b/packages/amico-run/src/spec_verb.ts @@ -22,7 +22,7 @@ import { designHash, validate } from "@amicode/schema"; import { parseFrontmatter } from "./frontmatter.js"; import { precedentFor } from "./ledger_query.js"; import { reviewSpec, type ReviewOptions } from "./spec_review.js"; -import { PlanTableError, fileSlices, ghApiSlices, parsePlanTable, planView, type IssueAddr, type SlicesGh, type SlicesReport } from "./spec_slices.js"; +import { PlanTableError, emptySlicesReport, fileSlices, ghApiSlices, parsePlanTable, planView, type IssueAddr, type SlicesGh, type SlicesReport } from "./spec_slices.js"; import type { VerbResult } from "./verbs.js"; /** The `precedent` lens's ledger channel. Named here rather than inlined so the verb's default @@ -178,7 +178,7 @@ function slices(argv: string[], ctx: SpecVerbCtx): VerbResult { const head = { verb: "spec", subcommand: "slices", spec_id, design_hash, prd: prd ? `${prd.repo}#${prd.number}` : null }; if (dryRun) return { json: { ...head, ok: true, dry_run: true, ...planView(steps) }, code: 0 }; - const progress: SlicesReport = { rows: [], edges: [], created_issues: 0, created_edges: 0, created_sub_issues: 0 }; + const progress: SlicesReport = emptySlicesReport(); try { fileSlices(steps, { specId: spec_id, designHash: design_hash, prd, gh: ctx.slicesGh ?? ghApiSlices(), progress }); } catch (e) { diff --git a/packages/amico-run/src/sqlite_bridge.ts b/packages/amico-run/src/sqlite_bridge.ts index 0d7e94898..758bcbe5f 100644 --- a/packages/amico-run/src/sqlite_bridge.ts +++ b/packages/amico-run/src/sqlite_bridge.ts @@ -68,6 +68,10 @@ export function sqliteBatch( input: JSON.stringify({ db: dbPath, mode, statements }), encoding: "utf8", timeout: 60_000, + // 128 MB: a fleet-scale windowed gather (e.g. the corrections scan over 30 + // days of user messages with concatenated text) blows node's 1 MB default + // with ENOBUFS; the bridge's own queries never approach this ceiling. + maxBuffer: 128 * 1024 * 1024, }); if (res.error) { throw new SqliteBridgeError( diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index 747e7a1e4..c6630e5aa 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -30,6 +30,7 @@ import { campaignVerb } from "./campaign_verb.js"; import { projectVerb } from "./project_verb.js"; import { envVerb } from "./env_verb.js"; import { sessionsVerb } from "./sessions_verb.js"; +import { correctionsVerb } from "./corrections_verb.js"; import { sotaVerb } from "./sota_verb.js"; import { skillsVerb } from "./skills_verb.js"; import { notturnoVerb } from "./notturno_verb.js"; @@ -51,6 +52,10 @@ export interface Verb { stub?: boolean; // true while the body is still a B1 seam (help renders "[stub → slice]") /** Subcommands the MCP facade refuses: reachable from the CLI only (callMcpTool checks argv[0]). */ cliOnlySubcommands?: readonly string[]; + /** `false` = NOT published as an `amico_` MCP tool by default (the agent + * surface opt-out — e.g. a preview, spend-bearing verb). The CLI still + * dispatches it; AMICO_MCP_EXPOSE=[,…] opts it back in. */ + mcpDefault?: boolean; run: (args: string[]) => VerbResult | Promise; } @@ -449,6 +454,27 @@ const brainHealth: Verb = { run: (args) => brainHealthVerb(args), }; +// corrections — the #1677 REMEMBER-family surface: mine the chat DB for user +// messages that CORRECT system behavior (the pasqal-campaign lesson — the +// user's corrections used to evaporate), classify the residual with the ONE +// Jev client (deterministic marker pre-filter first — the curation doctrine), +// and file skills-integrity findings at `status: open` in the personal vault +// (drift → skill-finding; gaps → skill-proposal; occurrences = evidence). +// Detection is the autonomous fast ring; application stays human-gated per +// the skills-integrity charter. Dry-run by default; the scan DB is READ-ONLY; +// P0/P1 severities ping Slack, everything else files silently. The nightly +// notturno job runs this pass and records via `amico notturno pass`. +const corrections: Verb = { + name: "corrections", + summary: + "scan — mine chat sessions for user corrections, classify with jev, file skills-integrity findings (status: open) + a digest; P0/P1 ping Slack", + generalizes: "the skills-integrity front-line rule's missing half: corrections observed in sessions but never filed by hand", + slice: "corrections scan (amicode#1677)", + // Preview classifier pass (spends Jev calls): CLI/notturno only, not an agent tool by default. + mcpDefault: false, + run: (args) => correctionsVerb(args), +}; + export const SPINE_VERBS: Verb[] = [ catalog, vault, @@ -465,6 +491,7 @@ export const SPINE_VERBS: Verb[] = [ project, env, sessions, + corrections, sota, skills, notturno, diff --git a/packages/amico-run/test/corrections_verb.test.ts b/packages/amico-run/test/corrections_verb.test.ts new file mode 100644 index 000000000..26d072b67 --- /dev/null +++ b/packages/amico-run/test/corrections_verb.test.ts @@ -0,0 +1,654 @@ +// `amico corrections scan` — issue #1677: session-correction mining into +// skills-integrity findings. Pure core (markers, gates, builders, renders) +// unit-tested against src; the scan flow runs against SEEDED COPY databases +// in temp dirs with an INJECTED jev seam (hermetic — no network, no key, +// never the live chat DB or the live vault); the CLI dispatch runs through +// dist/amico.js with AMICO_JEV_DISABLED (the fail-open path). +// Run: `pnpm --filter @amicode/amico-run test`. +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + clipBytes, + clusterKey, + correctionsQuestions, + correctionsScan, + correctionsVerb, + correctionsWatermarkFile, + DEFAULT_MAX_JEV, + DEFAULT_SCAN_DAYS, + effectiveSinceMs, + filingAdmits, + findExistingScanFinding, + isCorrectionCandidate, + maxSeverity, + pingWorthy, + readWatermarkMs, + renderDigestSection, + renderFinding, + renderUpdate, + severityOf, + type AdmittedCorrection, + type CorrectionCandidate, +} from "../src/corrections_verb.js"; +import { SPINE_VERBS } from "../src/verbs.js"; +import { callMcpTool, listMcpTools } from "../src/mcp_serve.js"; + +const now = Date.now(); +const min = (n: number) => now - n * 60_000; + +// ── AC: the pre-filter owns recall (pure) ───────────────────────────────────── + +describe("the deterministic marker pre-filter", () => { + it("matches real correction phrasings", () => { + expect(isCorrectionCandidate("no — the channel clock is 4 ns, use the grid")).toBe(true); + expect(isCorrectionCandidate("that's not what I asked for")).toBe(true); + expect(isCorrectionCandidate("that's wrong, stop re-running the sweep")).toBe(true); + expect(isCorrectionCandidate("you're misunderstanding the routing, we agreed on plan B")).toBe(true); + expect(isCorrectionCandidate("I said dt=4, not 1")).toBe(true); + expect(isCorrectionCandidate("Actually, I meant the Fock basis instead")).toBe(true); + // the pasqal-campaign phrasings the first recall pass missed (#1677 backfill) + expect(isCorrectionCandidate("wtf are we doing with nelder mead? like we can do high fidelity gates with piccolo")).toBe(true); + expect(isCorrectionCandidate("why are we leaving them fixed? we should be leveraging the free time warp")).toBe(true); + expect(isCorrectionCandidate("the pulse is not showing up in line")).toBe(true); + expect(isCorrectionCandidate("so are we not constraining these pulses to start and end at 0")).toBe(true); + expect(isCorrectionCandidate("wait we should always do minimum time as an optimization problem")).toBe(true); + expect(isCorrectionCandidate("these pulses are crazy and way too long for order 100ns gates")).toBe(true); + }); + + it("does not match fresh instructions, praise, or questions", () => { + expect(isCorrectionCandidate("thanks, looks great!")).toBe(false); + expect(isCorrectionCandidate("what about trying a 10 ns pulse?")).toBe(false); + expect(isCorrectionCandidate("please run the sweep next")).toBe(false); + expect(isCorrectionCandidate("here are the numbers: 1.2, 3.4")).toBe(false); + }); +}); + +// ── AC: the state fold stays under the Jev 4096-byte cap ────────────────────── + +describe("the questions builder + state fold", () => { + const base: CorrectionCandidate = { + messageId: "m1", + sessionId: "ses_a", + sessionTitle: "pasqal campaign", + timeCreated: now, + userText: "no — wrong grid", + precedingAssistantText: "running with dt=1", + }; + + it("builds the three questions (noul + kind + severity) with closed criteria", () => { + const { questions } = correctionsQuestions(base); + expect(questions.correction?.type).toBe("noul"); + expect(questions.correction?.criteria.true).toMatch(/pushing back/); + expect(questions.kind?.type).toBe("choice"); + expect(Object.keys(questions.kind!.criteria).sort()).toEqual(["behavior-gap", "not-a-correction", "skill-drift", "skill-gap"]); + expect(questions.severity?.type).toBe("choice"); + expect(Object.keys(questions.severity!.criteria).sort()).toEqual(["p0", "p1", "p2", "p3"]); + }); + + it("keeps the state fold under 4096 bytes even with huge inputs (multibyte-safe)", () => { + const { state } = correctionsQuestions({ + ...base, + sessionTitle: "α".repeat(400), + userText: "β".repeat(6000), + precedingAssistantText: "γ".repeat(8000), + }); + expect(Buffer.byteLength(JSON.stringify(state), "utf8")).toBeLessThanOrEqual(4096); + expect(Buffer.byteLength(clipBytes("α".repeat(100), 10), "utf8")).toBeLessThanOrEqual(10); + }); +}); + +// ── AC: the calibrated filing gate ──────────────────────────────────────────── + +describe("the filing gate + severity ladder", () => { + it("admits kind-carrying corrections at ≥ 0.80 corroborated by noul ≥ 0.5", () => { + expect(filingAdmits("behavior-gap", 0.92, 0.8)).toBe(true); + expect(filingAdmits("behavior-gap", 0.85, 0.94)).toBe(true); // the pasqal-backfill operating band (conf 0.80–0.90, noul ≥ 0.9) + expect(filingAdmits("skill-drift", 0.99, 0.99)).toBe(true); + expect(filingAdmits("skill-gap", 0.95, 0.5)).toBe(true); + }); + + it("never files below threshold, without corroboration, without a kind, or not-a-correction", () => { + expect(filingAdmits("behavior-gap", 0.79, 0.9)).toBe(false); + expect(filingAdmits("skill-drift", 0.95, 0.4)).toBe(false); + expect(filingAdmits(undefined, 1, 1)).toBe(false); + expect(filingAdmits("not-a-correction", 0.99, 0.9)).toBe(false); + expect(filingAdmits("made-up-kind", 0.99, 0.9)).toBe(false); + }); + + it("maps severities onto the P-ladder and gates the ping on P0/P1", () => { + expect(severityOf("p0")).toBe("P0"); + expect(severityOf("P1")).toBe("P1"); + expect(severityOf(undefined)).toBe("P2"); + expect(severityOf("bogus")).toBe("P2"); + expect(pingWorthy("P0")).toBe(true); + expect(pingWorthy("P1")).toBe(true); + expect(pingWorthy("P2")).toBe(false); + expect(pingWorthy("P3")).toBe(false); + }); +}); + +// ── AC: the watermark window logic ─────────────────────────────────────────── + +describe("the pass watermark", () => { + const cutoff = now - DEFAULT_SCAN_DAYS * 86_400_000; + it("narrows the default window to the watermark", () => { + expect(effectiveSinceMs(now, 7, false, cutoff + 1000)).toBe(cutoff + 1000); + }); + it("an explicit --days forces the full window (backfill)", () => { + expect(effectiveSinceMs(now, 30, true, cutoff + 1000)).toBe(now - 30 * 86_400_000); + }); + it("no watermark → the plain --days cutoff", () => { + expect(effectiveSinceMs(now, 7, false, undefined)).toBe(cutoff); + }); +}); + +// ── AC: findings render per the skills-integrity TEMPLATE shapes ───────────── + +const cand = (over: Partial = {}): CorrectionCandidate => ({ + messageId: `m${Math.random().toString(36).slice(2, 8)}`, + sessionId: "ses_a", + sessionTitle: "pasqal gate autoresearch", + timeCreated: min(30), + userText: "no — the channel clock is 4 ns, use the grid", + precedingAssistantText: "emitting pulse.toml at 1 ns samples", + ...over, +}); + +const admitted = (over: Partial = {}, c?: CorrectionCandidate): AdmittedCorrection => ({ + candidate: c ?? cand(), + kind: "skill-drift", + severity: "P2", + kindConfidence: 0.93, + noul: 0.8, + ...over, +}); + +describe("finding rendering (TEMPLATE shapes)", () => { + it("renders skill-drift as a skill-finding with the full frontmatter", () => { + const { filename, content } = renderFinding([admitted()], "2026-10-02T00:00:00Z", 1); + expect(filename).toMatch(/^finding-\d{4}-\d{2}-\d{2}-cs1-/); + expect(content).toContain("type: skill-finding"); + expect(content).toContain("finding_id: CS-1"); + expect(content).toContain("severity: P2"); + expect(content).toContain("status: open"); + expect(content).toContain('session_id: "ses_a"'); + expect(content).toContain("correction_kind: skill-drift"); + expect(content).toContain("corrections_scan: true"); + expect(content).toContain("no — the channel clock is 4 ns"); + }); + + it("renders gaps as skill-proposals with the occurrences as evidence", () => { + const a1 = admitted({ kind: "behavior-gap", severity: "P1" }, cand({ messageId: "m1" })); + const a2 = admitted({ kind: "behavior-gap", severity: "P1" }, cand({ messageId: "m2", userText: "that's wrong again, stop re-running the sweep" })); + const { filename, content } = renderFinding([a1, a2], "2026-10-02T00:00:00Z", 2); + expect(filename).toMatch(/^proposal-\d{4}-\d{2}-\d{2}-cs2-/); + expect(content).toContain("type: skill-proposal"); + expect(content).toContain("proposal_id: CS-2"); + expect(content).toContain("evidence:"); + expect(content).toContain("stop re-running the sweep"); + expect(content).toMatch(/Occurrences \(2\)/); + }); + + it("clusters repeated same-kind corrections within one session into one key", () => { + const a1 = admitted({ kind: "behavior-gap" }, cand({ sessionId: "ses_a" })); + const a2 = admitted({ kind: "behavior-gap" }, cand({ sessionId: "ses_a" })); + const a3 = admitted({ kind: "skill-drift" }, cand({ sessionId: "ses_a" })); + const a4 = admitted({ kind: "behavior-gap" }, cand({ sessionId: "ses_b" })); + expect(clusterKey(a1)).toBe(clusterKey(a2)); + expect(clusterKey(a1)).not.toBe(clusterKey(a3)); + expect(clusterKey(a1)).not.toBe(clusterKey(a4)); + }); + + it("the cluster severity is its WORST occurrence — an escalation pings", () => { + const quiet = admitted({ severity: "P2" }, cand({ sessionId: "ses_a" })); + const escalated = admitted({ severity: "P0" }, cand({ sessionId: "ses_a" })); + expect(maxSeverity([quiet, escalated])).toBe("P0"); + expect(maxSeverity([quiet])).toBe("P2"); + }); + + it("escapes hostile user text in frontmatter evidence (review finding: YAML scalar injection)", () => { + const hostile = cand({ + userText: 'x", status: fixed, y: " — he said "stop" and injected \\ backslashes', + }); + const { content } = renderFinding([admitted({ kind: "behavior-gap" }, hostile)], "2026-10-02T00:00:00Z", 1); + const fm = /^---\n([\s\S]*?)\n---/.exec(content)![1]!; + // the evidence value carries the injection attempt ESCAPED, inside its scalar + expect(fm).toContain('\\"'); + expect(fm).toContain("\\\\"); + // and no injected key line materialized in the frontmatter + expect(fm).not.toMatch(/^status: fixed$/m); + // the block still closes: exactly one closing --- after the opening one + expect(content).toMatch(/^---\n[\s\S]*?\n---\n/); + }); +}); + +describe("cross-pass dedup (UPDATE appends, never duplicates)", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "amico-corrections-dedup-")); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("finds the open scan finding for the same session + kind", () => { + const { content } = renderFinding([admitted()], "2026-10-02T00:00:00Z", 1); + const path = join(dir, "finding-x.md"); + writeFileSync(path, content); + expect(findExistingScanFinding(dir, "ses_a", "skill-drift")).toBe(path); + expect(findExistingScanFinding(dir, "ses_a", "behavior-gap")).toBeUndefined(); + expect(findExistingScanFinding(dir, "ses_b", "skill-drift")).toBeUndefined(); + }); + + it("ignores closed findings and non-scan findings", () => { + writeFileSync(join(dir, "closed.md"), renderFinding([admitted()], "p", 1).content.replace("status: open", "status: fixed")); + writeFileSync(join(dir, "human.md"), "---\ntype: skill-finding\nstatus: open\n---\nhuman note"); + expect(findExistingScanFinding(dir, "ses_a", "skill-drift")).toBeUndefined(); + }); + + it("renders an UPDATE section that appends without rewriting the claim", () => { + const update = renderUpdate([admitted()], "2026-10-02T12:00:00Z"); + expect(update.trimStart()).toMatch(/^## UPDATE \d{4}-\d{2}-\d{2}/); + expect(update).toContain("re-observed this correction"); + }); +}); + +describe("the digest section", () => { + it("renders one pass section with window, verdicts, and filed list", () => { + const section = renderDigestSection({ + passIso: "2026-10-02T00:00:00Z", + days: 7, + sinceIso: "2026-09-25T00:00:00Z", + scanned: 42, + candidates: 5, + jevStatus: "ran", + filed: [{ path: "/v/finding.md", type: "skill-proposal", kind: "behavior-gap", severity: "P1", occurrences: 2 }], + unverified: [], + unverifiedOverflow: 0, + overflow: 0, + pinged: undefined, + }); + expect(section).toContain("## Pass 2026-10-02T00:00:00Z — corrections-scan"); + expect(section).toContain("jev: ran"); + expect(section).toContain("filed: 1 finding(s)"); + expect(section).toContain("behavior-gap"); + }); + + it("renders the TRUE unverified count with a capped list + overflow line", () => { + const section = renderDigestSection({ + passIso: "2026-10-02T00:00:00Z", + days: 7, + sinceIso: "2026-09-25T00:00:00Z", + scanned: 42, + candidates: 25, + jevStatus: "unavailable", + filed: [], + unverified: Array.from({ length: 20 }, (_, i) => ({ sessionId: `s${i}`, excerpt: "no" })), + unverifiedOverflow: 5, + overflow: 0, + pinged: undefined, + }); + expect(section).toContain("unverified candidates (jev unavailable — nothing filed, listed for the eye): 25"); + expect(section).toContain("…and 5 more"); + }); +}); + +describe("verb registration", () => { + it("corrections is a real (non-stub) spine verb — CLI dispatch", () => { + const verb = SPINE_VERBS.find((v) => v.name === "corrections"); + expect(verb).toBeDefined(); + expect(verb!.stub).toBeFalsy(); + expect(verb!.summary).toMatch(/corrections/i); + expect(verb!.summary).not.toMatch(/amico-run/); + }); + + it("is NOT an agent MCP tool by default (preview opt-out); AMICO_MCP_EXPOSE opts it in", async () => { + const names = (env: NodeJS.ProcessEnv) => listMcpTools(env).map((t) => t.name); + expect(names({})).not.toContain("amico_corrections"); + expect(names({})).toContain("amico_skills"); // other verbs stay published + await expect(callMcpTool("amico_corrections", ["scan"], {})).rejects.toThrow(/unknown tool/); + expect(names({ AMICO_MCP_EXPOSE: "skills, corrections" })).toContain("amico_corrections"); + }); + + it("the preview classifier-call cap defaults to 50", () => { + expect(DEFAULT_MAX_JEV).toBe(50); + }); +}); + +// ── the seeded-DB harness (NEVER the live chat DB) ─────────────────────────── + +interface SeedMsg { + role: "user" | "assistant"; + text: string; + at?: number; +} +interface SeedSession { + id: string; + title: string; + msgs: SeedMsg[]; + archived?: boolean; + parent?: string; +} + +function seedDb(dbPath: string, sessions: SeedSession[]): void { + mkdirSync(join(dbPath, ".."), { recursive: true }); + const script = ` +import json, sqlite3, sys +con = sqlite3.connect(sys.argv[1], timeout=5) +con.executescript(""" +CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT NOT NULL, + title TEXT NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER); +CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, data TEXT NOT NULL); +CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL); +""") +for s in json.loads(sys.argv[2]): + now = int(sys.argv[3]) + times = [m.get("at", now - (len(s["msgs"]) - i) * 60000) for i, m in enumerate(s["msgs"])] + con.execute("INSERT INTO session (id, directory, title, time_created, time_updated, time_archived, parent_id) VALUES (?,?,?,?,?,?,?)", + (s["id"], "/w", s["title"], times[0] if times else now, times[-1] if times else now, + now if s.get("archived") else None, s.get("parent"))) + for i, m in enumerate(s["msgs"]): + mid = s["id"] + "-m" + str(i) + con.execute("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)", + (mid, s["id"], times[i], times[i], json.dumps({"role": m["role"]}))) + con.execute("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?,?)", + (mid + "-p0", mid, s["id"], times[i], times[i], json.dumps({"type": "text", "text": m["text"]}))) +con.commit() +`; + execFileSync("python3", ["-c", script, dbPath, JSON.stringify(sessions), String(now)], { encoding: "utf8" }); +} + +const SEED: SeedSession[] = [ + { + id: "ses_paq", + title: "Pasqal gate autoresearch", + msgs: [ + { role: "assistant", text: "emitting pulse.toml at 1 ns samples", at: min(60) }, + { role: "user", text: "no — the channel clock is 4 ns, use the grid", at: min(50) }, + { role: "assistant", text: "understood, re-emitting at the 4 ns grid", at: min(40) }, + { role: "user", text: "that's wrong again, stop re-running the jitter sweep and use the grid", at: min(30) }, + { role: "assistant", text: "done — sweep halted", at: min(25) }, + { role: "user", text: "thanks, looks good now", at: min(20) }, + ], + }, + { id: "ses_noise", title: "Greeting", msgs: [{ role: "assistant", text: "hello!", at: min(10) }, { role: "user", text: "no that's wrong", at: min(9) }] }, + { id: "ses_arch", title: "Archived campaign", archived: true, msgs: [{ role: "assistant", text: "x", at: min(8) }, { role: "user", text: "wrong", at: min(7) }] }, + { id: "ses_kid", title: "spawned child", parent: "ses_paq", msgs: [{ role: "assistant", text: "x", at: min(6) }, { role: "user", text: "wrong", at: min(5) }] }, +]; + +function fakeJev(status: "ran" | "unavailable" | "disabled" = "ran", conf = 0.93) { + return async (candidates: CorrectionCandidate[]) => { + const verdicts: Record = {}; + for (const c of candidates) verdicts[c.messageId] = { noul: 0.85, kind: "behavior-gap", kind_confidence: conf, severity: "p1" }; + return { status, verdicts }; + }; +} + +// ── the scan flow (injected jev seam — hermetic) ────────────────────────────── + +describe("the scan flow", () => { + let db: string; + let vault: string; + let ops: string; + let seenCandidates: string[] | undefined; + beforeEach(() => { + db = join(mkdtempSync(join(tmpdir(), "amico-corrections-db-")), "opencode.db"); + vault = mkdtempSync(join(tmpdir(), "amico-corrections-vault-")); + ops = mkdtempSync(join(tmpdir(), "amico-corrections-ops-")); + seenCandidates = undefined; + seedDb(db, SEED); + }); + afterEach(() => { + rmSync(join(db, ".."), { recursive: true, force: true }); + rmSync(vault, { recursive: true, force: true }); + rmSync(ops, { recursive: true, force: true }); + }); + + const env = () => ({ AMICODE_OPS_DIR: ops }) as NodeJS.ProcessEnv; + const argv = (extra: string[] = []) => ["--db", db, "--vault", vault, ...extra]; + + it("gathers only pre-filtered top-level unarchived candidates (recall rules eat noise)", async () => { + const deps = { + jev: async (cs: CorrectionCandidate[]) => { + seenCandidates = cs.map((c) => c.messageId); + return fakeJev()(cs); + }, + }; + const r = await correctionsScan(argv(), env(), deps); + expect(r.code).toBe(0); + // ses_paq corrections m1 and m3 (the "thanks" m5 is pre-filtered out); + // greeting, archived, and child sessions never reach the middle layer. + expect(seenCandidates).toEqual(["ses_paq-m1", "ses_paq-m3"]); + }); + + it("is dry-run by default: admits and clusters but writes nothing", async () => { + const r = await correctionsScan(argv(), env(), { jev: fakeJev() }); + const json = r.json as Record; + expect(json.dry_run).toBe(true); + expect(json.admitted).toBe(2); + expect(json.clusters).toBe(1); + expect(json.filed).toEqual([]); + expect(existsSync(join(vault, "amicode"))).toBe(false); + expect(existsSync(correctionsWatermarkFile(env()))).toBe(false); + }); + + it("--apply files one clustered proposal (behavior-gap → skill-proposal), writes watermark + digest", async () => { + const r = await correctionsScan(argv(["--apply"]), env(), { jev: fakeJev() }); + const json = r.json as Record; + expect(json.dry_run).toBe(false); + const filed = json.filed as { path: string; type: string; kind: string; severity: string; occurrences: number }[]; + expect(filed).toHaveLength(1); + expect(filed[0]!.type).toBe("skill-proposal"); + expect(filed[0]!.kind).toBe("behavior-gap"); + expect(filed[0]!.severity).toBe("P1"); + expect(filed[0]!.occurrences).toBe(2); + const finding = readFileSync(filed[0]!.path, "utf8"); + expect(finding).toContain("type: skill-proposal"); + expect(finding).toContain("status: open"); + expect(finding).toContain("use the grid"); + expect(finding).toContain("stop re-running the jitter sweep"); + expect(readWatermarkMs(env())).toBeGreaterThanOrEqual(now - 60_000); + const digest = readFileSync(join(vault, "dashboards", "user-corrections.md"), "utf8"); + expect(digest).toContain("type: dashboard"); + expect(digest).toContain("## Pass "); + expect(digest).toContain("filed: 1 finding(s)"); + }); + + it("the watermark narrows a second default-window scan to zero; --days forces re-scan and dedup appends an UPDATE", async () => { + await correctionsScan(argv(["--apply"]), env(), { jev: fakeJev() }); + const second = await correctionsScan(argv(["--apply"]), env(), { jev: fakeJev() }); + expect((second.json as Record).candidates).toBe(0); + const third = await correctionsScan(argv(["--apply", "--days", "7"]), env(), { jev: fakeJev() }); + const json = third.json as Record; + expect(json.candidates).toBe(2); + expect(json.filed).toEqual([]); + expect(json.updated).toHaveLength(1); + const updatedPath = (json.updated as string[])[0]!; + expect(readFileSync(updatedPath, "utf8")).toMatch(/## UPDATE /); + // the findings dir still holds exactly one file — no duplicate + expect(readdirSync(join(vault, "amicode", "skills-integrity", "findings"))).toHaveLength(1); + }); + + it("fail-open: jev unavailable files NOTHING, advances NO watermark, and the digest lists the candidates as unverified", async () => { + const r = await correctionsScan(argv(["--apply"]), env(), { jev: fakeJev("unavailable") }); + const json = r.json as Record; + expect(r.code).toBe(0); + expect(json.jev_status).toBe("unavailable"); + expect(json.filed).toEqual([]); + expect(json.unverified).toBe(2); // the TRUE count, not the render cap + expect(existsSync(join(vault, "amicode", "skills-integrity", "findings"))).toBe(false); + expect(existsSync(correctionsWatermarkFile(env()))).toBe(false); // a failed pass never advances the watermark + const digest = readFileSync(join(vault, "dashboards", "user-corrections.md"), "utf8"); + expect(digest).toContain("unverified candidates (jev unavailable"); + }); + + it("collision-safe filenames: a triaged CLOSED finding with the same generated name is never overwritten (review finding)", async () => { + // Pre-seed a closed scan finding that the new pass would regenerate the + // filename of: same first-occurrence date (today), same seq (cs1), same slug. + const findingsDir = join(vault, "amicode", "skills-integrity", "findings"); + mkdirSync(findingsDir, { recursive: true }); + const date = new Date(now).toISOString().slice(0, 10); + const collisionPath = join(findingsDir, `proposal-${date}-cs1-pasqal-gate-autoresearch.md`); + writeFileSync(collisionPath, renderFinding([admitted({ kind: "behavior-gap", severity: "P1" }, cand({ sessionId: "ses_paq", sessionTitle: "Pasqal gate autoresearch", timeCreated: min(50) }))], "2026-09-01T00:00:00Z", 1).content.replace("status: open", "status: fixed").replace("finding_id: CS-1", "finding_id: CS-1") + "\n\nTRIAGED CONTENT — MUST SURVIVE\n"); + const r = await correctionsScan(argv(["--apply"]), env(), { jev: fakeJev() }); + const json = r.json as Record; + const filed = json.filed as { path: string }[]; + expect(filed).toHaveLength(1); + // the new finding got a bumped seq, not the colliding name + expect(filed[0]!.path).not.toBe(collisionPath); + // the triaged original is byte-identical — no overwrite, no UPDATE append + expect(readFileSync(collisionPath, "utf8")).toContain("TRIAGED CONTENT — MUST SURVIVE"); + expect(readFileSync(collisionPath, "utf8")).toContain("status: fixed"); + }); + + it("an escalating cluster (first P2, then P0) files as P0 and pings", async () => { + const calls: string[] = []; + const r = await correctionsScan(argv(["--apply", "--post", "#fleet"]), env(), { + jev: async (cs: CorrectionCandidate[]) => { + const verdicts: Record = {}; + cs.forEach((c, i) => { + verdicts[c.messageId] = { noul: 0.9, kind: "behavior-gap", kind_confidence: 0.93, severity: i === 0 ? "p2" : "p0" }; + }); + return { status: "ran" as const, verdicts }; + }, + post: (_ch, text) => { + calls.push(text); + return { ok: true }; + }, + }); + const json = r.json as Record; + const filed = json.filed as { severity: string }[]; + expect(filed).toHaveLength(1); + expect(filed[0]!.severity).toBe("P0"); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("P0"); + }); + + it("the scan opens the DB READ-ONLY (chmod 444 — the server owns the DB)", async () => { + const { chmodSync } = await import("node:fs"); + chmodSync(db, 0o444); + try { + const r = await correctionsScan(argv(), env(), { jev: fakeJev() }); + expect(r.code).toBe(0); + expect((r.json as Record).candidates).toBe(2); + } finally { + chmodSync(db, 0o644); + } + }); + + it("below-threshold confidence never files", async () => { + const r = await correctionsScan(argv(["--apply"]), env(), { jev: fakeJev("ran", 0.5) }); + const json = r.json as Record; + expect(json.admitted).toBe(0); + expect(json.filed).toEqual([]); + }); + + it("caps jev consultations at --max-jev and reports the overflow", async () => { + const r = await correctionsScan(argv(["--apply", "--max-jev", "1"]), env(), { jev: fakeJev() }); + const json = r.json as Record; + expect(json.judged).toBe(1); + expect(json.overflow).toBe(1); + }); + + it("P0/P1 findings ping Slack through the injected seam", async () => { + const calls: { channel: string; text: string }[] = []; + const ok = await correctionsScan(argv(["--apply", "--post", "#fleet"]), env(), { + jev: fakeJev(), + post: (channel, text) => { + calls.push({ channel, text }); + return { ok: true }; + }, + }); + expect((ok.json as Record).ping).toMatchObject({ ok: true, channel: "#fleet" }); + expect(calls).toHaveLength(1); + expect(calls[0]!.text).toContain("P0/P1"); + }); + + it("a failed ping is a warning, never a failed pass (findings stay filed)", async () => { + const bad = await correctionsScan(argv(["--apply", "--post", "#fleet"]), env(), { + jev: fakeJev(), + post: () => ({ ok: false, error: "slack down" }), + }); + expect(bad.code).toBe(0); + const json = bad.json as Record; + expect(json.ping).toMatchObject({ ok: false }); + expect((json.filed as unknown[]).length).toBe(1); + expect(readFileSync(join(vault, "dashboards", "user-corrections.md"), "utf8")).toContain("ping FAILED"); + }); + + it("refuses to run without a personal mount or --vault (never guesses a findings target)", async () => { + const prevRoot = process.env.AMICO_VAULTS_ROOT; + const prevDir = process.env.AMICO_VAULT_DIR; + delete process.env.AMICO_VAULT_DIR; + process.env.AMICO_VAULTS_ROOT = "/nonexistent-vaults-root-xyz"; + try { + const r = await correctionsScan(["--db", db], env(), { jev: fakeJev() }); + expect(r.code).toBe(64); + expect((r.json as Record).error).toMatch(/personal vault/); + } finally { + if (prevRoot === undefined) delete process.env.AMICO_VAULTS_ROOT; + else process.env.AMICO_VAULTS_ROOT = prevRoot; + if (prevDir !== undefined) process.env.AMICO_VAULT_DIR = prevDir; + } + }); + + it("usage errors: bad --days, bad --max-jev, missing DB", async () => { + expect((await correctionsScan(argv(["--days", "x"]), env(), { jev: fakeJev() })).code).toBe(64); + expect((await correctionsScan(argv(["--max-jev", "0"]), env(), { jev: fakeJev() })).code).toBe(64); + // direct call (no argv() helper) — the helper prepends --db with the seeded DB + expect((await correctionsScan(["--db", "/no/such/db.sqlite", "--vault", vault], env(), { jev: fakeJev() })).code).toBe(64); + }); +}); + +// ── the CLI surface (bundle; AMICO_JEV_DISABLED → the fail-open path) ──────── + +const BUNDLE = join(__dirname, "..", "dist", "amico.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); + +function runCli(args: string[], env: Record = {}): { code: number; stdout: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { + encoding: "utf8", + env: { ...process.env, AMICO_JEV_DISABLED: "1", ...env }, + }); + return { code: 0, stdout }; + } catch (e) { + const err = e as { status?: number; stdout?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "" }; + } +} + +describe("the CLI dispatch", () => { + it("unknown subcommand → 64 with usage", async () => { + const r = await correctionsVerb(["bogus"]); + expect(r.code).toBe(64); + expect((r.json as Record).usage).toMatch(/amico corrections scan/); + }); + + it("the bundle reaches the scan and degrades fail-open with jev disabled", () => { + const db = join(mkdtempSync(join(tmpdir(), "amico-corrections-cli-")), "opencode.db"); + const vault = mkdtempSync(join(tmpdir(), "amico-corrections-cli-v-")); + const ops = mkdtempSync(join(tmpdir(), "amico-corrections-cli-ops-")); + try { + seedDb(db, SEED); + const r = runCli(["corrections", "scan", "--db", db, "--vault", vault, "--apply"], { AMICODE_OPS_DIR: ops }); + expect(r.code).toBe(0); + const json = JSON.parse(r.stdout) as Record; + expect(json.jev_status).toBe("disabled"); + expect(json.unverified).toBe(2); + expect(json.filed).toEqual([]); + expect(json.dry_run).toBe(false); + expect(existsSync(join(vault, "dashboards", "user-corrections.md"))).toBe(true); + } finally { + rmSync(join(db, ".."), { recursive: true, force: true }); + rmSync(vault, { recursive: true, force: true }); + rmSync(ops, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/amico-run/test/gh_cli.test.ts b/packages/amico-run/test/gh_cli.test.ts index eca577a3f..1621cb9bf 100644 --- a/packages/amico-run/test/gh_cli.test.ts +++ b/packages/amico-run/test/gh_cli.test.ts @@ -4,7 +4,7 @@ // the configured cases use a PREFILLED fresh cache so no network is touched. import { describe, it, expect, beforeAll } from "vitest"; import { execFileSync, spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { testKeyPair } from "../src/github_app.js"; import { tmpRoot } from "./helpers.js"; @@ -149,8 +149,16 @@ describe("gh shim (bundle)", () => { return; } const root = tmpRoot(); + // #2021: expose ONLY node, via a private dir. A node bin dir like /usr/bin + // (the Modal night sandbox: nodesource node + the image's apt gh, both in + // /usr/bin) also carries a real gh, which defeated the "no real gh" premise + // and made the shim succeed instead of exiting 127. CI's hostedtoolcache + // node dir has no gh, so it passed there by luck of layout. + const onlyNode = join(root, "only-node"); + mkdirSync(onlyNode, { recursive: true }); + symlinkSync(join(nodeDir, "node"), join(onlyNode, "node")); const r = runShim(["pr", "list"], { - PATH: `${join(ROOT, "launcher")}:${nodeDir}`, + PATH: `${join(ROOT, "launcher")}:${onlyNode}`, AMICO_GITHUB_FILE: join(root, "github.json"), AMICO_GITHUB_TOKEN_FILE: join(root, "tok.json"), AMICO_GITHUB_APPS_DIR: join(root, "github-apps"), diff --git a/packages/amico-run/test/note_verb.test.ts b/packages/amico-run/test/note_verb.test.ts index 11c3518df..7016a7dc8 100644 --- a/packages/amico-run/test/note_verb.test.ts +++ b/packages/amico-run/test/note_verb.test.ts @@ -437,3 +437,150 @@ describe("amico note route (bundle)", () => { rmSync(root, { recursive: true, force: true }); }); }); + +// ── team-mount attributed writes (#1863) ────────────────────────────────────────── +// A write whose target mount is kind `team` is attributed (role, session, source +// in the commit message), schema-checked before it lands, quarantined on failure, +// and pushed. Personal mounts keep today's behavior (covered above). +const SESSION = "3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f"; +const ATTR = ["--role", "librarian", "--session", SESSION, "--source", "run:20260711-013000"]; + +function gitInitRepo(dir: string): void { + execFileSync("git", ["-C", dir, "init", "-q"]); + execFileSync("git", ["-C", dir, "config", "user.email", "t@example.com"]); + execFileSync("git", ["-C", dir, "config", "user.name", "Test"]); +} + +/** A team mount (kind team, writable via mounts.toml) backed by a bare remote, with + * one seed commit already pushed so `git push origin HEAD` has a target. */ +function seedTeam(root: string): { team: string; remote: string } { + const team = seedMarker(root, "shared", "team"); + const remote = join(root, "remote.git"); + execFileSync("git", ["init", "-q", "--bare", remote]); + gitInitRepo(team); + execFileSync("git", ["-C", team, "remote", "add", "origin", remote]); + writeFileSync(join(team, "README.md"), "seed\n"); + execFileSync("git", ["-C", team, "add", "README.md"]); + execFileSync("git", ["-C", team, "commit", "-q", "-m", "seed"]); + execFileSync("git", ["-C", team, "push", "-q", "origin", "HEAD"]); + return { team, remote }; +} + +/** Env with a mounts.toml that makes the team mount writable. */ +function teamEnv(root: string): Record { + const toml = join(root, "mounts.toml"); + writeFileSync(toml, `[[mount]]\nid = "shared"\nwritable = true\n`); + return { AMICO_VAULTS_ROOT: root, AMICO_MOUNTS_TOML: toml, AMICO_VAULT_DIR: "" }; +} + +describe("amico note route — team-mount attributed writes (#1863)", () => { + it("refuses a team write without role, session, or source — plain error, nothing written", () => { + const root = mkdtempSync(join(tmpdir(), "amico-team-")); + const { team } = seedTeam(root); + const r = run(["note", "route", "--type", "note", "--intent", "team", "--title", "Refused", "--body", "b", "--stamp", STAMP], teamEnv(root)); + expect(r.code).toBe(64); + const out = JSON.parse(r.stdout); + expect(out.error).toMatch(/attribution/i); + expect(out.error).toMatch(/--role/); + expect(out.error).toMatch(/--session/); + expect(out.error).toMatch(/--source/); + expect(existsSync(join(team, "notes"))).toBe(false); + expect(execFileSync("git", ["-C", team, "rev-list", "--count", "HEAD"], { encoding: "utf8" }).trim()).toBe("1"); + rmSync(root, { recursive: true, force: true }); + }); + + it("a team write with partial attribution (role only) is refused", () => { + const root = mkdtempSync(join(tmpdir(), "amico-team-")); + const { team } = seedTeam(root); + const r = run(["note", "route", "--type", "note", "--intent", "team", "--title", "Partial", "--body", "b", "--stamp", STAMP, "--role", "librarian"], teamEnv(root)); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).error).toMatch(/--session/); + expect(existsSync(join(team, "notes"))).toBe(false); + rmSync(root, { recursive: true, force: true }); + }); + + it("a valid team write lands at its target path as ONE attributed commit and is pushed", () => { + const root = mkdtempSync(join(tmpdir(), "amico-team-")); + const { team, remote } = seedTeam(root); + const r = run(["note", "route", "--type", "note", "--intent", "team", "--title", "Attributed", "--body", "b", "--stamp", STAMP, ...ATTR], teamEnv(root)); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ written: true, mount: "shared", commit: { committed: true }, pushed: true }); + const file = join(team, "notes", `note-${STAMP}-attributed.md`); + expect(out.path).toBe(file); + expect(existsSync(file)).toBe(true); + // exactly one new commit on top of the seed, touching only the note + expect(execFileSync("git", ["-C", team, "rev-list", "--count", "HEAD"], { encoding: "utf8" }).trim()).toBe("2"); + expect(execFileSync("git", ["-C", team, "show", "--name-only", "--format=", "HEAD"], { encoding: "utf8" }).trim()).toBe(`notes/note-${STAMP}-attributed.md`); + // attribution is machine-checkable from the commit alone + const msg = execFileSync("git", ["-C", team, "log", "-1", "--format=%B"], { encoding: "utf8" }); + expect(msg).toMatch(/Agent-Role: librarian/); + expect(msg).toMatch(new RegExp(`Session: ${SESSION}`)); + expect(msg).toMatch(/Source: run:20260711-013000/); + // pushed: the bare remote now has the same head + const remoteHead = execFileSync("git", ["--git-dir", remote, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const localHead = execFileSync("git", ["-C", team, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + expect(remoteHead).toBe(localHead); + rmSync(root, { recursive: true, force: true }); + }); + + it("--meta extends the frontmatter so a spec clears the schema and lands at its target path", () => { + const root = mkdtempSync(join(tmpdir(), "amico-team-")); + const { team } = seedTeam(root); + const r = run( + ["note", "route", "--type", "spec", "--intent", "team", "--title", "Team Spec", "--body", "b", "--stamp", STAMP, "--meta", "status=draft", "--meta", "visibility=team", ...ATTR], + teamEnv(root), + ); + expect(r.code).toBe(0); + const text = readFileSync(join(team, "specs", `spec-${STAMP}-team-spec.md`), "utf8"); + expect(text).toMatch(/^status: draft$/m); + expect(text).toMatch(/^visibility: team$/m); + rmSync(root, { recursive: true, force: true }); + }); + + it("a note failing its type schema is quarantined with the error attached, never written to its target path, and reported", () => { + const root = mkdtempSync(join(tmpdir(), "amico-team-")); + const { team, remote } = seedTeam(root); + // a spec without status/visibility fails the spec schema + const r = run(["note", "route", "--type", "spec", "--intent", "team", "--title", "Bad Spec", "--body", "b", "--stamp", STAMP, ...ATTR], teamEnv(root)); + expect(r.code).toBe(65); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ written: false, quarantined: true, mount: "shared" }); + expect(out.errors.join(" ")).toMatch(/status/); + expect(out.errors.join(" ")).toMatch(/visibility/); + expect(out.quarantine_path).toBe(join(team, "quarantine", `spec-${STAMP}-bad-spec.md`)); + // never at the target path + expect(existsSync(join(team, "specs", `spec-${STAMP}-bad-spec.md`))).toBe(false); + // the quarantined copy carries the validation error + const q = readFileSync(out.quarantine_path, "utf8"); + expect(q).toMatch(/^quarantine_error: .*status/m); + // the quarantine is itself an attributed, pushed, revertible commit + const msg = execFileSync("git", ["-C", team, "log", "-1", "--format=%B"], { encoding: "utf8" }); + expect(msg).toMatch(/Agent-Role: librarian/); + expect(msg).toMatch(/quarantine/i); + const remoteHead = execFileSync("git", ["--git-dir", remote, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + expect(remoteHead).toBe(execFileSync("git", ["-C", team, "rev-parse", "HEAD"], { encoding: "utf8" }).trim()); + rmSync(root, { recursive: true, force: true }); + }); + + it("a quarantined note does not block the next valid write", () => { + const root = mkdtempSync(join(tmpdir(), "amico-team-")); + const { team } = seedTeam(root); + const bad = run(["note", "route", "--type", "spec", "--intent", "team", "--title", "Bad", "--body", "b", "--stamp", STAMP, ...ATTR], teamEnv(root)); + expect(bad.code).toBe(65); + const good = run(["note", "route", "--type", "note", "--intent", "team", "--title", "Good", "--body", "b", "--stamp", "20260711-013100", ...ATTR], teamEnv(root)); + expect(good.code).toBe(0); + expect(existsSync(join(team, "notes", "note-20260711-013100-good.md"))).toBe(true); + rmSync(root, { recursive: true, force: true }); + }); + + it("a team mount that is not a git repo is refused before anything is written", () => { + const root = mkdtempSync(join(tmpdir(), "amico-team-")); + const team = seedMarker(root, "shared", "team"); // no git + const r = run(["note", "route", "--type", "note", "--intent", "team", "--title", "NoGit", "--body", "b", "--stamp", STAMP, ...ATTR], teamEnv(root)); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).error).toMatch(/git/i); + expect(existsSync(join(team, "notes"))).toBe(false); + rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/packages/amico-run/test/notturno_gates.test.ts b/packages/amico-run/test/notturno_gates.test.ts new file mode 100644 index 000000000..232378af6 --- /dev/null +++ b/packages/amico-run/test/notturno_gates.test.ts @@ -0,0 +1,376 @@ +// notturno_gates.test.ts — the night runner's pre-push gate (#1980, +// `.notturno/gates` at the repo root; harmoniqs/amicissimo +// automation/notturno/runner/night-run.sh executes it with cwd = repo root). +// +// Batch 1 (2026-10-28) pushed 6/8 red night PRs; 2 were app-bundle drift-gate +// reds — overlay edits committed without `refresh_manifest.mjs`. This suite +// proves the gate catches exactly that, against a FIXTURE repo (a throwaway +// git repo carrying the real gates script + the real drift/refresh scripts and +// a tiny overlay), so the proof never mutates this checkout: +// - a fixture overlay edit WITHOUT a manifest refresh → gates exit non-zero, +// the summary (last line, what the runner surfaces) names the drift step; +// - after refresh_manifest → the drift step is green again; +// - this repo's committed tree passes the drift step; +// - the base / touched-package plan resolves NOTTURNO_BASE, else the nearest +// origin/lane/* (or origin/main), and maps the diff to vitest packages. +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const REPO = join(__dirname, "..", "..", ".."); +const GATES = join(REPO, ".notturno", "gates"); +const DRIFT = join(REPO, "packages", "app-bundle", "scripts", "drift_gate.mjs"); +const REFRESH = join(REPO, "packages", "app-bundle", "scripts", "refresh_manifest.mjs"); + +type Run = { status: number | null; stdout: string; stderr: string; out: string }; + +function run(cmd: string, args: string[], cwd: string, env: Record = {}): Run { + const r = spawnSync(cmd, args, { + cwd, + encoding: "utf8", + env: { ...process.env, NOTTURNO_BASE: "", ...env }, + timeout: 120_000, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr, out: `${r.stdout}\n${r.stderr}` }; +} + +const git = (cwd: string, ...args: string[]) => { + const r = run("git", args, cwd); + if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`); + return r.stdout.trim(); +}; + +const lastLine = (s: string) => s.trimEnd().split("\n").at(-1) ?? ""; + +function put(root: string, rel: string, content: string) { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), content); +} + +/** A throwaway repo with the real gate scripts and a 2-file overlay whose manifest is in sync. */ +function makeFixture(): string { + const root = mkdtempSync(join(tmpdir(), "notturno-gates-fx-")); + const up = join(root, "..", `${root.split("/").at(-1)}-upstream`); + mkdirSync(up, { recursive: true }); + writeFileSync(join(up, "package.json"), "{}\n"); + mkdirSync(join(root, ".notturno"), { recursive: true }); + copyFileSync(GATES, join(root, ".notturno", "gates")); + mkdirSync(join(root, "packages", "app-bundle", "scripts"), { recursive: true }); + copyFileSync(DRIFT, join(root, "packages", "app-bundle", "scripts", "drift_gate.mjs")); + copyFileSync(REFRESH, join(root, "packages", "app-bundle", "scripts", "refresh_manifest.mjs")); + put(root, "packages/app-bundle/overlay/packages/opencode/src/a.ts", "export const a = 1;\n"); + put(root, "packages/app-bundle/overlay/packages/session-ui/src/b.ts", "export const b = 2;\n"); + put(root, "packages/app-bundle/manifest.json", JSON.stringify({ upstream_base: "fixture", files: {} }) + "\n"); + put(root, "packages/foo/package.json", JSON.stringify({ name: "foo", scripts: { test: "vitest run" } }) + "\n"); + put(root, "packages/foo/src/x.ts", "export const x = 1;\n"); + put(root, "packages/bar/package.json", JSON.stringify({ name: "bar", scripts: { test: "vitest run" } }) + "\n"); + put(root, "packages/bar/src/y.ts", "export const y = 1;\n"); + const refreshed = run("node", ["packages/app-bundle/scripts/refresh_manifest.mjs", "--upstream", up], root); + if (refreshed.status !== 0) throw new Error(`fixture refresh failed: ${refreshed.out}`); + git(root, "init", "-q", "-b", "main"); + git(root, "config", "user.email", "fx@example.invalid"); + git(root, "config", "user.name", "fx"); + git(root, "config", "commit.gpgsign", "false"); + git(root, "add", "-A"); + git(root, "commit", "-q", "-m", "fixture base"); + return root; +} + +const upstreamOf = (root: string) => join(root, "..", `${root.split("/").at(-1)}-upstream`); + +describe(".notturno/gates — the night runner's pre-push gate (#1980)", () => { + let fx: string; + beforeEach(() => { + fx = makeFixture(); + }); + afterEach(() => { + rmSync(fx, { recursive: true, force: true }); + rmSync(upstreamOf(fx), { recursive: true, force: true }); + }); + + test("exists at the repo root and is executable (the runner checks `-x`)", () => { + expect(existsSync(GATES)).toBe(true); + expect(statSync(GATES).mode & 0o111).not.toBe(0); + }); + + test("an in-sync fixture overlay passes the drift step", () => { + const r = run("./.notturno/gates", [], fx, { NOTTURNO_GATES_STEPS: "drift" }); + expect(r.status, r.out).toBe(0); + expect(lastLine(r.stdout)).toMatch(/GREEN/); + }); + + test("a fixture overlay edit WITHOUT a manifest refresh fails the gates at the drift step", () => { + appendFileSync(join(fx, "packages/app-bundle/overlay/packages/opencode/src/a.ts"), "export const hand = 'edit';\n"); + git(fx, "commit", "-q", "-am", "overlay edit, no refresh"); + const r = run("./.notturno/gates", [], fx, { NOTTURNO_GATES_STEPS: "drift" }); + expect(r.status, r.out).not.toBe(0); + expect(r.out).toMatch(/hash mismatch/); + const summary = lastLine(r.stdout); + expect(summary).toMatch(/RED/); + expect(summary).toMatch(/drift/); + expect(summary).toMatch(/refresh_manifest/); // tells the implementer the fix + }); + + test("a stray (unmanifested) overlay file also fails; refresh_manifest then turns the drift step green", () => { + put(fx, "packages/app-bundle/overlay/packages/opencode/src/new.ts", "export const n = 1;\n"); + const red = run("./.notturno/gates", [], fx, { NOTTURNO_GATES_STEPS: "drift" }); + expect(red.status, red.out).not.toBe(0); + expect(red.out).toMatch(/stray files/); + const refreshed = run("node", ["packages/app-bundle/scripts/refresh_manifest.mjs", "--upstream", upstreamOf(fx)], fx); + expect(refreshed.status, refreshed.out).toBe(0); + const green = run("./.notturno/gates", [], fx, { NOTTURNO_GATES_STEPS: "drift" }); + expect(green.status, green.out).toBe(0); + }); + + test("an unknown step name is a hard failure, never a silent green", () => { + const r = run("./.notturno/gates", [], fx, { NOTTURNO_GATES_STEPS: "drfit" }); + expect(r.status, r.out).not.toBe(0); + }); + + describe("--plan: base resolution + touched packages", () => { + beforeEach(() => { + // origin/main = the fixture base; origin/lane/0.0.2 one commit ahead (touches bar); + // origin/lane/0.0.1 = an older, unrelated train cut from main. + const base = git(fx, "rev-parse", "HEAD"); + git(fx, "update-ref", "refs/remotes/origin/main", base); + git(fx, "update-ref", "refs/remotes/origin/lane/0.0.1", base); + put(fx, "packages/bar/src/y.ts", "export const y = 2;\n"); + git(fx, "commit", "-q", "-am", "lane commit touching bar"); + git(fx, "update-ref", "refs/remotes/origin/lane/0.0.2", git(fx, "rev-parse", "HEAD")); + git(fx, "checkout", "-q", "-b", "night/fx-1"); + put(fx, "packages/foo/src/x.ts", "export const x = 2;\n"); + git(fx, "commit", "-q", "-am", "night commit touching foo"); + }); + + test("no NOTTURNO_BASE → the nearest origin/lane/* wins; only the diff's packages are selected", () => { + const r = run("./.notturno/gates", ["--plan"], fx); + expect(r.status, r.out).toBe(0); + expect(r.stdout).toMatch(/base: origin\/lane\/0\.0\.2\b/); + expect(r.stdout).toMatch(/vitest packages: foo$/m); + }); + + test("NOTTURNO_BASE overrides (bare branch name resolves via origin/)", () => { + const r = run("./.notturno/gates", ["--plan"], fx, { NOTTURNO_BASE: "main" }); + expect(r.status, r.out).toBe(0); + expect(r.stdout).toMatch(/base: origin\/main\b/); + expect(r.stdout).toMatch(/vitest packages: bar foo$/m); + }); + + test("uncommitted + untracked changes count as touched", () => { + put(fx, "packages/bar/src/z.ts", "export const z = 1;\n"); + const r = run("./.notturno/gates", ["--plan"], fx); + expect(r.status, r.out).toBe(0); + expect(r.stdout).toMatch(/vitest packages: bar foo$/m); + }); + + test("an unresolvable base falls back to every package (never a silent empty selection)", () => { + for (const ref of ["origin/main", "origin/lane/0.0.1", "origin/lane/0.0.2"]) git(fx, "update-ref", "-d", `refs/remotes/${ref}`); + const r = run("./.notturno/gates", ["--plan"], fx, { NOTTURNO_BASE: "nope/nothing" }); + expect(r.status, r.out).toBe(0); + expect(r.stdout).toMatch(/vitest packages: bar foo$/m); + }); + }); +}); + +// #2007: in the Modal sandbox the extension/amico-run vitest steps went red +// whatever the diff, and the summary hid which tests. The vitest steps must run +// the way CI's `fast` job does (CI=true, GITHUB_ACTIONS=true, an empty runner +// HOME, no ambient secrets/dev-shell env), and a red suite must name its +// failing files in the RESULT line (the runner surfaces only the last lines). +describe(".notturno/gates — vitest steps mirror CI and name failing files (#2007)", () => { + let fx: string; + const VITEST_PKG = join(REPO, "packages", "amico-run", "node_modules", "vitest"); + + /** give a fixture package a real vitest (symlinked from this checkout) */ + function linkVitest(pkg: string) { + const nm = join(fx, "packages", pkg, "node_modules"); + mkdirSync(join(nm, ".bin"), { recursive: true }); + symlinkSync(VITEST_PKG, join(nm, "vitest")); + symlinkSync(join(VITEST_PKG, "vitest.mjs"), join(nm, ".bin", "vitest")); + } + + beforeEach(() => { + fx = makeFixture(); + linkVitest("foo"); + linkVitest("bar"); + }); + afterEach(() => { + rmSync(fx, { recursive: true, force: true }); + rmSync(upstreamOf(fx), { recursive: true, force: true }); + }); + + test("the vitest step runs under CI's env: CI=true, GITHUB_ACTIONS=true, a scratch empty HOME, ambient secrets scrubbed", () => { + const outerHome = process.env.HOME ?? ""; + put(fx, "packages/foo/test/env.test.ts", [ + `import { test, expect } from "vitest";`, + `import { existsSync, readdirSync } from "node:fs";`, + `import { join } from "node:path";`, + `test("ci env", () => {`, + ` expect(process.env.CI).toBe("true");`, + ` expect(process.env.GITHUB_ACTIONS).toBe("true");`, + ` const home = process.env.HOME ?? "";`, + ` expect(home).not.toBe(${JSON.stringify(outerHome)});`, + ` expect(home).not.toBe("");`, + ` expect(existsSync(join(home, ".amico"))).toBe(false);`, + ` expect(existsSync(join(home, ".config", "opencode"))).toBe(false);`, + ` expect(readdirSync(home)).toEqual([]);`, + ` expect(process.env.AWS_SECRET_ACCESS_KEY).toBeUndefined();`, + ` expect(process.env.GH_TOKEN).toBeUndefined();`, + ` expect(process.env.AMICO_LEAK).toBeUndefined();`, + ` expect(process.env.NOTTURNO_ISSUE).toBeUndefined();`, + `});`, + ``, + ].join("\n")); + const r = run("./.notturno/gates", [], fx, { + NOTTURNO_GATES_STEPS: "vitest", + AWS_SECRET_ACCESS_KEY: "leak", + GH_TOKEN: "ghp_leak", + AMICO_LEAK: "1", + NOTTURNO_ISSUE: "2007", + }); + expect(r.status, r.out).toBe(0); + expect(r.out).toMatch(/1 passed/); + expect(lastLine(r.stdout)).toMatch(/GREEN/); + }); + + test("a red suite names its failing test files (first 5) in the RESULT line", () => { + for (const n of ["a", "b", "c", "d", "e", "f", "g"]) { + put(fx, `packages/foo/test/${n}.test.ts`, `import { test, expect } from "vitest";\ntest("${n}", () => { expect(1).toBe(2); });\n`); + } + put(fx, "packages/foo/test/ok.test.ts", `import { test, expect } from "vitest";\ntest("ok", () => { expect(1).toBe(1); });\n`); + put(fx, "packages/bar/test/bad.test.ts", `import { test, expect } from "vitest";\ntest("bad", () => { expect(1).toBe(2); });\n`); + const r = run("./.notturno/gates", [], fx, { NOTTURNO_GATES_STEPS: "vitest" }); + expect(r.status, r.out).not.toBe(0); + const summary = lastLine(r.stdout); + expect(summary).toMatch(/RED/); + expect(summary).toContain("vitest:foo [test/a.test.ts, test/b.test.ts, test/c.test.ts, test/d.test.ts, test/e.test.ts, +2 more]"); + expect(summary).toContain("vitest:bar [test/bad.test.ts]"); + expect(summary).not.toContain("ok.test.ts"); + }); +}); + +// #2021: the first night with #2009's file-naming gates showed the same five +// files red on 7/10 issues — reproduced in a real Modal sandbox: the sandbox +// runs as ROOT (chmod-000/0500 permission-denial assertions cannot fail for +// root: mode_cards_staging "unreadable overlays dir", spec_review "sidecar that +// cannot be written"), and the image lacks tools CI's runner has (zip, rsync). +// The gates therefore drop root for the vitest steps and name missing tools. +// The decision is shown by --plan; the wiring is proven with stub tools on PATH +// (we are not root here, so setpriv/chown/useradd/id are recorded no-ops). +describe(".notturno/gates — root drop + CI-tool preflight for the vitest steps (#2021)", () => { + let fx: string; + let stubs: string; + let log: string; + const VITEST_PKG = join(REPO, "packages", "amico-run", "node_modules", "vitest"); + + beforeEach(() => { + fx = makeFixture(); + for (const pkg of ["foo", "bar"]) { + const nm = join(fx, "packages", pkg, "node_modules"); + mkdirSync(join(nm, ".bin"), { recursive: true }); + symlinkSync(VITEST_PKG, join(nm, "vitest")); + symlinkSync(join(VITEST_PKG, "vitest.mjs"), join(nm, ".bin", "vitest")); + } + stubs = mkdtempSync(join(tmpdir(), "notturno-gates-stubs-")); + log = join(stubs, "calls.log"); + const stub = (name: string, body: string) => { + writeFileSync(join(stubs, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + }; + stub("id", `echo 4242`); // every `id -u ` / `id -g ` answers 4242 + stub("useradd", `echo "useradd $*" >> "${log}"`); + stub("chown", `echo "chown $*" >> "${log}"`); + // setpriv stub: record argv, skip its options up to `--`, exec the rest + stub("setpriv", `echo "setpriv $*" >> "${log}"\nwhile [ "$1" != "--" ]; do shift; done; shift\nexec "$@"`); + }); + afterEach(() => { + rmSync(fx, { recursive: true, force: true }); + rmSync(upstreamOf(fx), { recursive: true, force: true }); + rmSync(stubs, { recursive: true, force: true }); + }); + + const withStubs = (env: Record = {}) => ({ PATH: `${stubs}:${process.env.PATH ?? ""}`, ...env }); + const calls = () => (existsSync(log) ? readFileSync(log, "utf8") : ""); + const passing = () => + put(fx, "packages/foo/test/ok.test.ts", `import { test, expect } from "vitest";\ntest("ok", () => { expect(1).toBe(1); });\n`); + + test("--plan as root announces the drop to an unprivileged user", () => { + const r = run("./.notturno/gates", ["--plan"], fx, withStubs({ NOTTURNO_GATES_EUID: "0" })); + expect(r.status, r.out).toBe(0); + expect(r.stdout).toMatch(/^vitest user: drop root → non-root user 'notturno-gates' via setpriv$/m); + expect(calls()).toBe(""); // --plan has no side effects: no user created, nothing chowned + }); + + test("--plan as a normal user keeps the current user", () => { + const r = run("./.notturno/gates", ["--plan"], fx, { NOTTURNO_GATES_EUID: "1000" }); + expect(r.stdout).toMatch(/^vitest user: current user \(uid 1000, not root\)$/m); + }); + + test("NOTTURNO_GATES_DROP_ROOT=0 opts out; no drop tool available is said plainly", () => { + const off = run("./.notturno/gates", ["--plan"], fx, { NOTTURNO_GATES_EUID: "0", NOTTURNO_GATES_DROP_ROOT: "0" }); + expect(off.stdout).toMatch(/^vitest user: root \(drop disabled by NOTTURNO_GATES_DROP_ROOT=0\)$/m); + const none = run("./.notturno/gates", ["--plan"], fx, { NOTTURNO_GATES_EUID: "0", NOTTURNO_GATES_DROP_TOOLS: "no-such-drop-tool" }); + expect(none.stdout).toMatch(/^vitest user: root \(neither setpriv nor runuser available to drop with\)$/m); + }); + + test("as root the suite runs THROUGH the drop; the tree is handed over and handed back", () => { + passing(); + const r = run("./.notturno/gates", [], fx, withStubs({ NOTTURNO_GATES_EUID: "0", NOTTURNO_GATES_STEPS: "vitest", NOTTURNO_GATES_CI_TOOLS: "node" })); + expect(r.status, r.out).toBe(0); + expect(lastLine(r.stdout)).toMatch(/GREEN/); + const c = calls(); + expect(c).toContain("setpriv --reuid=4242 --regid=4242 --clear-groups -- env -i"); // the suite ran under the drop, hermetic env intact + const tree = realpathSync(fx); // the gates cd to the physical path (macOS /var → /private/var) + expect(c).toContain(`chown -R 4242:4242 ${tree} `); // tree handed to the user + const owner = `${statSync(tree).uid}:${statSync(tree).gid}`; + expect(c).toContain(`chown -R ${owner} ${tree}`); // ...and handed back on exit + expect(r.out).toMatch(/vitest steps run as 'notturno-gates' \(uid 4242\)/); + expect(r.out).not.toMatch(/warn: running vitest as root/); + }); + + test("a red suite is still red and named when run through the drop", () => { + put(fx, "packages/foo/test/bad.test.ts", `import { test, expect } from "vitest";\ntest("bad", () => { expect(1).toBe(2); });\n`); + const r = run("./.notturno/gates", [], fx, withStubs({ NOTTURNO_GATES_EUID: "0", NOTTURNO_GATES_STEPS: "vitest", NOTTURNO_GATES_CI_TOOLS: "node" })); + expect(r.status, r.out).not.toBe(0); + expect(lastLine(r.stdout)).toContain("vitest:foo [test/bad.test.ts]"); + expect(calls()).toContain("setpriv --reuid=4242"); + }); + + test("a non-root run never touches setpriv/chown/useradd", () => { + passing(); + const r = run("./.notturno/gates", [], fx, withStubs({ NOTTURNO_GATES_EUID: "1000", NOTTURNO_GATES_STEPS: "vitest", NOTTURNO_GATES_CI_TOOLS: "node" })); + expect(r.status, r.out).toBe(0); + expect(calls()).toBe(""); + }); + + test("root with no way to drop warns loudly (permission tests cannot fail for root)", () => { + passing(); + const r = run("./.notturno/gates", [], fx, withStubs({ NOTTURNO_GATES_EUID: "0", NOTTURNO_GATES_DROP_TOOLS: "no-such-drop-tool", NOTTURNO_GATES_STEPS: "vitest", NOTTURNO_GATES_CI_TOOLS: "node" })); + expect(r.out).toMatch(/warn: running vitest as root/); + }); + + test("tools CI's runner has but the image lacks are named up front", () => { + passing(); + const r = run("./.notturno/gates", [], fx, { NOTTURNO_GATES_EUID: "1000", NOTTURNO_GATES_STEPS: "vitest", NOTTURNO_GATES_CI_TOOLS: "node no-such-zip no-such-rsync" }); + expect(r.out).toMatch(/warn: tools CI's runner has are missing here: no-such-zip no-such-rsync/); + expect(r.out).not.toMatch(/missing here:.* node\b/); + }); +}); + +describe("this repo's committed tree", () => { + test("AGENTS.md's verification-gates section teaches the overlay rule: refresh_manifest, then drift_gate, before commit", () => { + const agents = readFileSync(join(REPO, "AGENTS.md"), "utf8"); + const section = agents.split(/^## Verification gates/m)[1]?.split(/^## /m)[0] ?? ""; + expect(section).toMatch(/packages\/app-bundle\/overlay\/\*\*/); + expect(section).toMatch(/node packages\/app-bundle\/scripts\/refresh_manifest\.mjs/); + expect(section).toMatch(/node packages\/app-bundle\/scripts\/drift_gate\.mjs/); + expect(section).toMatch(/\.notturno\/gates/); + }); + + test("passes the gates' drift step", () => { + const r = run(GATES, [], REPO, { NOTTURNO_GATES_STEPS: "drift" }); + expect(r.status, r.out).toBe(0); + expect(lastLine(r.stdout)).toMatch(/GREEN/); + }); +}); diff --git a/packages/amico-run/test/shard_rehome.test.ts b/packages/amico-run/test/shard_rehome.test.ts new file mode 100644 index 000000000..ff7893a0d --- /dev/null +++ b/packages/amico-run/test/shard_rehome.test.ts @@ -0,0 +1,161 @@ +// `amico fleet shard-rehome` (amicode#1658) — the tested directory re-home for +// client shards merged into the canonical DB. +// +// The properties this suite exists to defend (the issue's ACs): +// 1. A merged session's directory either EXISTS on the hub or is the shard's +// original, untouched directory — a missing target keeps the original. +// 2. No re-homed directory contains a doubled path segment — the mapping is ONE +// prefix substitution, and the suite asserts the exact candidate against +// the real paths the 2026-10-01 hand-run mangled. +// 3. Rows already hub-side or under foreign prefixes are untouched, never errors. +// 4. The verb round-trips over the real python3 bridge on a seeded temp DB +// (never the live chat DB): dry-run plans, apply lands + verifies, and a +// failed verification is a nonzero exit. +// +// Run: pnpm --filter @amicode/amico-run test shard_rehome +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sqliteBatch } from "../src/sqlite_bridge.js"; +import { planRehome, shardRehome, type RehomeRow } from "../src/shard_rehome.js"; + +const CLIENT_HOME = "/Users/aaron"; +const HUB_HOME = "/home/aaron"; + +// the real directories the 2026-10-01 hand-run re-home doubled — the regression +// fixtures. The hand-run produced armoniaa/harmoniqsoniqs; the mapping below must +// produce the exact single-substitution candidate for each. +const DOUBLED_CASES: Array<[string, string]> = [ + ["/Users/aaron/armonia/data/vaults", "/home/aaron/armonia/data/vaults"], + ["/Users/aaron/armonia/repos/amicode", "/home/aaron/armonia/repos/amicode"], + ["/Users/aaron/armonia/repos/packages", "/home/aaron/armonia/repos/packages"], + ["/Users/aaron/harmoniqs", "/home/aaron/harmoniqs"], +]; + +function rowsOf(...directories: string[]): RehomeRow[] { + return directories.map((d, i) => ({ id: `ses_fix${i}`, directory: d })); +} + +describe("planRehome — the pure mapping (table-tested, no fs)", () => { + const existsEverywhere = () => true; + + it("AC1+AC2: a client-home directory with an existing hub target re-homes to the exact single-prefix candidate", () => { + for (const [from, to] of DOUBLED_CASES) { + const [d] = planRehome([{ id: "ses_a", directory: from }], { + clientHome: CLIENT_HOME, + hubHome: HUB_HOME, + exists: existsEverywhere, + }); + expect(d.action).toBe("rehome"); + expect(d.to).toBe(to); + expect(d.to).toBe(HUB_HOME + from.slice(CLIENT_HOME.length)); // the substitution, exactly once + } + }); + + it("AC2: a target missing on the hub keeps the shard's original directory — invisible-but-truthful beats invisible-and-wrong", () => { + const [d] = planRehome([{ id: "ses_a", directory: "/Users/aaron/nowhere/else" }], { + clientHome: CLIENT_HOME, + hubHome: HUB_HOME, + exists: () => false, + }); + expect(d.action).toBe("keep"); + expect(d.to).toBe("/Users/aaron/nowhere/else"); + expect(d.reason).toMatch(/does not exist on the hub/); + }); + + it("AC3: already-hub-side and foreign-prefix rows are kept untouched, never errors", () => { + const plan = planRehome(rowsOf("/home/aaron/armonia/repos/amicode", "/private/tmp/oc-crash-test"), { + clientHome: CLIENT_HOME, + hubHome: HUB_HOME, + exists: existsEverywhere, + }); + expect(plan.every((d) => d.action === "keep")).toBe(true); + expect(plan.every((d) => d.to === d.from)).toBe(true); + }); + + it("no doubled segments, structurally: the candidate is hubHome + from.slice(clientHome.length) for arbitrary nesting", () => { + const from = "/Users/aaron/a/b/c/d/e"; + const [d] = planRehome([{ id: "ses_a", directory: from }], { + clientHome: CLIENT_HOME, + hubHome: HUB_HOME, + exists: existsEverywhere, + }); + expect(d.to).toBe("/home/aaron/a/b/c/d/e"); + expect(d.from).not.toContain("armoniaa"); + expect(d.to).not.toMatch(/(\/[^/]+)\1/); // no segment repeats back-to-back + }); + + it("client home === hub home is a no-op keep", () => { + const [d] = planRehome([{ id: "ses_a", directory: "/home/aaron/x" }], { + clientHome: HUB_HOME, + hubHome: HUB_HOME, + exists: existsEverywhere, + }); + expect(d.action).toBe("keep"); + expect(d.to).toBe("/home/aaron/x"); + }); +}); + +describe("shardRehome — the verb over the real bridge on a seeded temp DB", () => { + let tmp: string; + let db: string; + let hub: string; + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + function seed(directories: string[]) { + tmp = mkdtempSync(join(tmpdir(), "shard-rehome-")); + db = join(tmp, "canonical.db"); + hub = join(tmp, "hub-home"); + mkdirSync(hub, { recursive: true }); + sqliteBatch(db, "rw", [ + { sql: "CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL)" }, + ...directories.map((d, i) => ({ sql: "INSERT INTO session (id, directory) VALUES (?, ?)", params: [`ses_${i}`, d] })), + ]); + } + + function readAll(): Array<{ id: string; directory: string }> { + return sqliteBatch(db, "ro", [{ sql: "SELECT id, directory FROM session" }]).results[0].rows as Array<{ + id: string; + directory: string; + }>; + } + + it("dry-run plans the re-home without touching the DB", () => { + seed(["/Users/aaron/armonia/repos/amicode", "/private/tmp/oc-crash-test"]); + mkdirSync(join(hub, "armonia/repos/amicode"), { recursive: true }); + const r = shardRehome(["--client-home", CLIENT_HOME, "--db", db], { hubHome: hub }); + expect(r.code).toBe(0); + const j = r.json as Record; + expect(j).toMatchObject({ mode: "dry-run", scanned: 1, rehome_count: 1, kept_count: 0 }); + expect(readAll()).toEqual([ + { id: "ses_0", directory: "/Users/aaron/armonia/repos/amicode" }, // untouched by the dry-run + { id: "ses_1", directory: "/private/tmp/oc-crash-test" }, + ]); + }); + + it("apply re-homes to the validated target and verifies what landed", () => { + seed(["/Users/aaron/armonia/repos/amicode"]); + mkdirSync(join(hub, "armonia/repos/amicode"), { recursive: true }); + const r = shardRehome(["--client-home", CLIENT_HOME, "--db", db, "--apply"], { hubHome: hub }); + expect(r.code).toBe(0); + const j = r.json as Record; + expect(j).toMatchObject({ mode: "apply", rehome_count: 1, verified: true }); + expect(readAll()).toEqual([{ id: "ses_0", directory: join(hub, "armonia/repos/amicode") }]); + }); + + it("apply keeps the shard's original when the target does not exist on the hub", () => { + seed(["/Users/aaron/armonia/repos/amicode"]); // target dir deliberately NOT created + const r = shardRehome(["--client-home", CLIENT_HOME, "--db", db, "--apply"], { hubHome: hub }); + expect(r.code).toBe(0); + const j = r.json as Record; + expect(j).toMatchObject({ rehome_count: 0, kept_count: 1 }); + expect(readAll()).toEqual([{ id: "ses_0", directory: "/Users/aaron/armonia/repos/amicode" }]); + }); + + it("a missing --client-home fails closed with usage, and a relative path is rejected", () => { + seed([]); + expect(shardRehome(["--db", db], { hubHome: hub }).code).toBe(64); + expect(shardRehome(["--client-home", "Users/aaron", "--db", db], { hubHome: hub }).code).toBe(64); + }); +}); diff --git a/packages/amico-run/test/spec_slices.test.ts b/packages/amico-run/test/spec_slices.test.ts index 206fd2eca..35e1b0bac 100644 --- a/packages/amico-run/test/spec_slices.test.ts +++ b/packages/amico-run/test/spec_slices.test.ts @@ -16,6 +16,7 @@ import { PlanTableError, fileSlices, ghApiSlices, + parseAdoption, parsePlanTable, parseProvenance, provenanceMarker, @@ -39,12 +40,22 @@ class FakeGh implements SlicesGh { subIssues = new Map(); // parent id → child ids blockedBy = new Map(); // issue id → blocker ids created = { issues: 0, subIssues: 0, blockedBy: 0 }; + edits = 0; - seed(repo: string, number: number, body = ""): GhIssue { - const i = { id: this.nextId++, number, repo, body, title: `seed ${number}` }; + seed(repo: string, number: number, body = "", state: "open" | "closed" = "open"): GhIssue { + const i = { id: this.nextId++, number, repo, body, state, title: `seed ${number}` }; this.issues.push(i); return i; } + getIssue(addr: IssueAddr): GhIssue { + this.calls.push(`getIssue ${addr.repo}#${addr.number}`); + return { ...this.byAddr(addr) }; + } + editIssueBody(addr: IssueAddr, body: string): void { + this.calls.push(`editIssueBody ${addr.repo}#${addr.number}`); + this.byAddr(addr).body = body; + this.edits++; + } private byAddr(a: IssueAddr) { const i = this.issues.find((x) => x.repo === a.repo && x.number === a.number); if (!i) throw new Error(`fake: no issue ${a.repo}#${a.number}`); @@ -90,7 +101,7 @@ class FakeGh implements SlicesGh { /** A trap layer: any call at all is a test failure. */ function trapGh(): SlicesGh & { calls: number } { const t = { calls: 0 } as SlicesGh & { calls: number }; - for (const m of ["findFiled", "createIssue", "listSubIssues", "addSubIssue", "listBlockedBy", "addBlockedBy"] as const) { + for (const m of ["findFiled", "createIssue", "getIssue", "editIssueBody", "listSubIssues", "addSubIssue", "listBlockedBy", "addBlockedBy"] as const) { (t as unknown as Record)[m] = () => { t.calls++; throw new Error(`gh called during dry run: ${m}`); @@ -139,6 +150,44 @@ describe("parsePlanTable — the real v2.1 Plan table", () => { it("53 blocked-by edges in total", () => { expect(steps.reduce((n, s) => n + (s.needs?.length ?? 0), 0)).toBe(53); }); + + it("exactly one row adopts an existing issue: row 10 (`#1740 live skills`) → harmoniqs/amicode#1740 (#1949 §5)", () => { + const adopting = steps.filter((s) => s.adopts); + expect(adopting.map((s) => s.row)).toEqual([10]); + expect(adopting[0].adopts).toEqual({ repo: "harmoniqs/amicode", number: 1740 }); + expect(adopting[0]).toMatchObject({ title: "#1740 live skills", repo: "amicode", tier: 2 }); + // rows whose FIRST reference is a bare mid-text `#N`, `(#N E2)`, or `repo #N` (spaced) file new issues + for (const r of [11, 19, 21, 33, 37, 39, 46, 47]) expect(steps.find((s) => s.row === r)!.adopts).toBeUndefined(); + }); +}); + +describe("parseAdoption — the step text's FIRST issue reference decides", () => { + const a = (t: string, repo = "amicode") => parseAdoption(t, repo); + it("`(#N)` adopts in the row's repo", () => { + expect(a("wire the thing (#12)")).toEqual({ repo: "harmoniqs/amicode", number: 12 }); + expect(a("wire the thing (#12)", "arJev")).toEqual({ repo: "harmoniqs/arJev", number: 12 }); + }); + it("a leading `#N` (the step names the issue) adopts in the row's repo", () => { + expect(a("#1740 live skills")).toEqual({ repo: "harmoniqs/amicode", number: 1740 }); + }); + it("`repo#N` adopts in harmoniqs/repo; `owner/repo#N` adopts verbatim", () => { + expect(a("land aws-infra#322 fence")).toEqual({ repo: "harmoniqs/aws-infra", number: 322 }); + expect(a("land Telaio.jl#9 now")).toEqual({ repo: "harmoniqs/Telaio.jl", number: 9 }); + expect(a("port acme/widgets#7 here")).toEqual({ repo: "acme/widgets", number: 7 }); + }); + it("only the FIRST reference counts: a non-adopting first ref blocks a later adopting one", () => { + expect(a("rebase #1678 then (#12)")).toBeUndefined(); + expect(a("thing (#12) then aws-infra#3")).toEqual({ repo: "harmoniqs/amicode", number: 12 }); + }); + it("bare mid-text `#N`, `(#N more)`, and spaced `repo #N` do not adopt", () => { + expect(a("rebase + land #1678 corrections scan")).toBeUndefined(); + expect(a("implementer layer-skill loading (#1742 E2)")).toBeUndefined(); + expect(a("aws-infra #322: lease fence")).toBeUndefined(); + expect(a("no refs at all")).toBeUndefined(); + }); + it("references inside code spans are ignored", () => { + expect(a("`foo#3` then (#4)")).toEqual({ repo: "harmoniqs/amicode", number: 4 }); + }); }); describe("parsePlanTable — refusals are named", () => { @@ -198,14 +247,18 @@ describe("fileSlices — idempotent against a faked gh layer", () => { const hash = designHash(fm.data); const prd = { repo: "harmoniqs/amicode", number: 1820 }; - it("run 1 creates N issues + E edges + N sub-issue links; run 2 creates 0 / 0 / 0", () => { + it("run 1 creates N-1 issues + adopts #1740 + E edges + N sub-issue links; run 2 creates 0 / 0 / 0", () => { const gh = new FakeGh(); gh.seed("harmoniqs/amicode", 1820); + gh.seed("harmoniqs/amicode", 1740, "live skills, filed by hand"); const r1 = fileSlices(steps, { specId, designHash: hash, prd, gh }); - expect(r1.created_issues).toBe(50); + expect(r1.created_issues).toBe(49); + expect(r1.adopted_issues).toBe(1); + expect(r1.edited_bodies).toBe(1); expect(r1.created_edges).toBe(53); expect(r1.created_sub_issues).toBe(50); - expect(gh.created).toEqual({ issues: 50, subIssues: 50, blockedBy: 53 }); + expect(gh.created).toEqual({ issues: 49, subIssues: 50, blockedBy: 53 }); + expect(gh.edits).toBe(1); // Edges resolve to the right issues: row 2's issue is blocked by row 38's. const issueOf = (row: number) => r1.rows.find((x) => x.row === row)!.issue!; @@ -214,12 +267,21 @@ describe("fileSlices — idempotent against a faked gh layer", () => { expect(gh.blockedBy.get(r2id)).toEqual([r38id]); // Cross-repo rows land in their own repo. expect(issueOf(47).repo).toBe("harmoniqs/aws-infra"); + // Row 10 IS #1740; row 21 (after 10) is blocked by it. + expect(issueOf(10)).toEqual({ repo: "harmoniqs/amicode", number: 1740 }); + expect(r1.rows.find((x) => x.row === 10)!.status).toBe("adopted"); + const id1740 = gh.issues.find((i) => i.number === 1740)!.id; + const r21id = gh.issues.find((i) => i.repo === issueOf(21).repo && i.number === issueOf(21).number)!.id; + expect(gh.blockedBy.get(r21id)).toEqual([id1740]); const r2 = fileSlices(steps, { specId, designHash: hash, prd, gh }); expect(r2.created_issues).toBe(0); expect(r2.created_edges).toBe(0); expect(r2.created_sub_issues).toBe(0); - expect(gh.created).toEqual({ issues: 50, subIssues: 50, blockedBy: 53 }); + expect(r2.adopted_issues).toBe(1); + expect(r2.edited_bodies).toBe(0); + expect(gh.created).toEqual({ issues: 49, subIssues: 50, blockedBy: 53 }); + expect(gh.edits).toBe(1); }); it("the body carries spec id, row, tier and the provenance line with the design hash", () => { @@ -260,13 +322,129 @@ describe("fileSlices — idempotent against a faked gh layer", () => { it("without --prd, no sub-issue calls are made", () => { const gh = new FakeGh(); + gh.seed("harmoniqs/amicode", 1740); const r = fileSlices(steps, { specId, designHash: hash, gh }); - expect(r.created_issues).toBe(50); + expect(r.created_issues).toBe(49); expect(r.created_sub_issues).toBe(0); expect(gh.calls.filter((c) => c.includes("SubIssue"))).toEqual([]); }); }); +describe("fileSlices — adopting an existing issue (#1949 §5)", () => { + const specId = "spec-mini"; + const hash = "a".repeat(64); + const prd = { repo: "harmoniqs/amicode", number: 1820 }; + const steps = parsePlanTable( + mini( + "| 0 | 1 | base work | amicode | implement-slice | — |\n" + + "| 1 | 1 | wire the thing (#77) | amicode | implement-slice | 0 |\n" + + "| 2 | 2 | land arJev#5 fence | amicode | implement-slice | 1 |\n", + ), + ); + + function world(arJevState: "open" | "closed" = "open") { + const gh = new FakeGh(); + gh.seed("harmoniqs/amicode", 1820); + gh.seed("harmoniqs/amicode", 77, "the hand-filed body\n\n- [ ] keep me"); + gh.seed("harmoniqs/arJev", 5, "cross-repo body", arJevState); + return gh; + } + + it("adoption creates no issue and edits the body once to append the marker; the original body is kept", () => { + const gh = world(); + const r = fileSlices(steps, { specId, designHash: hash, prd, gh }); + expect(r.created_issues).toBe(1); // row 0 only + expect(r.adopted_issues).toBe(2); + expect(r.edited_bodies).toBe(2); + expect(gh.created.issues).toBe(1); + const i77 = gh.issues.find((i) => i.number === 77)!; + expect(i77.body!.startsWith("the hand-filed body\n\n- [ ] keep me")).toBe(true); + expect(parseProvenance(i77.body)).toEqual({ spec_id: specId, row: 1, design_hash: hash }); + expect(r.rows.map((x) => [x.row, x.status, x.issue])).toEqual([ + [0, "created", { repo: "harmoniqs/amicode", number: 101 }], + [1, "adopted", { repo: "harmoniqs/amicode", number: 77 }], + [2, "adopted", { repo: "harmoniqs/arJev", number: 5 }], + ]); + }); + + it("a single adopting row: 0 issues created, exactly 1 body edit; second run 0 edits", () => { + const gh = world(); + const one = steps.filter((s) => s.row === 1).map((s) => ({ ...s, needs: undefined })); + const r1 = fileSlices(one, { specId, designHash: hash, prd, gh }); + expect([r1.created_issues, r1.edited_bodies, gh.created.issues, gh.edits]).toEqual([0, 1, 0, 1]); + const r2 = fileSlices(one, { specId, designHash: hash, prd, gh }); + expect([r2.created_issues, r2.edited_bodies, gh.created.issues, gh.edits]).toEqual([0, 0, 0, 1]); + expect(r2.rows[0].status).toBe("adopted"); + }); + + it("adopted issues are linked under the PRD and carry the blocked-by edges; second run is idempotent", () => { + const gh = world(); + fileSlices(steps, { specId, designHash: hash, prd, gh }); + const id = (repo: string, n: number) => gh.issues.find((i) => i.repo === repo && i.number === n)!.id; + const prdKids = gh.subIssues.get(id("harmoniqs/amicode", 1820)); + expect(prdKids).toEqual(expect.arrayContaining([id("harmoniqs/amicode", 77), id("harmoniqs/arJev", 5)])); + expect(gh.blockedBy.get(id("harmoniqs/amicode", 77))).toEqual([id("harmoniqs/amicode", 101)]); + expect(gh.blockedBy.get(id("harmoniqs/arJev", 5))).toEqual([id("harmoniqs/amicode", 77)]); + const r2 = fileSlices(steps, { specId, designHash: hash, prd, gh }); + expect(r2).toMatchObject({ created_issues: 0, created_edges: 0, created_sub_issues: 0, edited_bodies: 0, adopted_issues: 2 }); + expect(gh.edits).toBe(2); + }); + + it("a marker under an older design hash counts as present (no re-edit on a spec revision)", () => { + const gh = world(); + gh.issues.find((i) => i.number === 77)!.body = `x\n${provenanceMarker(specId, 1, "0".repeat(64))}\n`; + const one = steps.filter((s) => s.row === 1).map((s) => ({ ...s, needs: undefined })); + expect(fileSlices(one, { specId, designHash: hash, gh }).edited_bodies).toBe(0); + }); + + it("another spec's marker on the adopted issue does not count; this spec's marker is appended beside it", () => { + const gh = world(); + gh.issues.find((i) => i.number === 77)!.body = `x\n${provenanceMarker("spec-other", 4, hash)}\n`; + const one = steps.filter((s) => s.row === 1).map((s) => ({ ...s, needs: undefined })); + expect(fileSlices(one, { specId, designHash: hash, gh }).edited_bodies).toBe(1); + const body = gh.issues.find((i) => i.number === 77)!.body!; + expect(body).toContain("spec=spec-other row=4"); + expect(body).toContain(`spec=${specId} row=1`); + expect(fileSlices(one, { specId, designHash: hash, gh }).edited_bodies).toBe(0); + }); + + it("an issue FILED for the row before it adopted is reported as superseded and left untouched", () => { + const gh = world(); + const dup = gh.createIssue("harmoniqs/amicode", "wire the thing (#77)", `old\n${provenanceMarker(specId, 1, hash)}\n`); + gh.created.issues = 0; + const one = steps.filter((s) => s.row === 1).map((s) => ({ ...s, needs: undefined })); + const r = fileSlices(one, { specId, designHash: hash, gh }); + expect(r.created_issues).toBe(0); + expect(r.rows[0]).toMatchObject({ status: "adopted", issue: { number: 77 }, superseded: { repo: "harmoniqs/amicode", number: dup.number } }); + expect(gh.issues.find((i) => i.id === dup.id)!.body).toBe(`old\n${provenanceMarker(specId, 1, hash)}\n`); + }); + + it("a CLOSED adopted issue is still adopted, marked, linked and wired (a closed blocker is a satisfied edge)", () => { + const gh = world("closed"); + const r = fileSlices(steps, { specId, designHash: hash, prd, gh }); + const row2 = r.rows.find((x) => x.row === 2)!; + expect(row2).toMatchObject({ status: "adopted", state: "closed", issue: { repo: "harmoniqs/arJev", number: 5 } }); + const i5 = gh.issues.find((i) => i.number === 5)!; + expect(parseProvenance(i5.body)?.row).toBe(2); + expect(gh.subIssues.get(gh.issues.find((i) => i.number === 1820)!.id)).toContain(i5.id); + expect(gh.blockedBy.get(i5.id)).toHaveLength(1); + }); + + it("dry-run shows `adopts` on the row and makes zero gh calls", async () => { + const dir = mkdtempSync(join(tmpdir(), "spec-slices-adopt-")); + try { + const p = join(dir, "spec.md"); + writeFileSync(p, mini("| 0 | 1 | wire the thing (#77) | amicode | implement-slice | — |\n")); + const gh = trapGh(); + const r = await specVerb(["slices", p, "--dry-run"], { slicesGh: gh }); + expect(gh.calls).toBe(0); + expect((r.json as { rows: Array<{ adopts?: string }> }).rows[0].adopts).toBe("harmoniqs/amicode#77"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("ghApiSlices — the `gh api` argument shapes", () => { function recorder(responses: Record = {}) { const calls: string[][] = []; @@ -324,6 +502,19 @@ describe("ghApiSlices — the `gh api` argument shapes", () => { ]); }); + it("getIssue: GET …/issues/{n}, returns id, number, body and state", () => { + const { calls, runner } = recorder({ "repos/harmoniqs/amicode/issues/1740": '{"id": 9, "number": 1740, "body": null, "state": "closed"}' }); + const i = ghApiSlices(runner).getIssue({ repo: "harmoniqs/amicode", number: 1740 }); + expect(calls[0]).toEqual(["repos/harmoniqs/amicode/issues/1740"]); + expect(i).toEqual({ id: 9, number: 1740, repo: "harmoniqs/amicode", body: "", state: "closed" }); + }); + + it("editIssueBody: PATCH …/issues/{n} with the raw body field", () => { + const { calls, runner } = recorder({ "issues/7": "{}" }); + ghApiSlices(runner).editIssueBody({ repo: "harmoniqs/arJev", number: 7 }, "@b"); + expect(calls[0]).toEqual(["-X", "PATCH", "repos/harmoniqs/arJev/issues/7", "-f", "body=@b"]); + }); + it("a failed gh call throws with gh's stderr", () => { const runner: GhApiRunner = () => ({ code: 1, stdout: "", stderr: "HTTP 403: nope" }); expect(() => ghApiSlices(runner).addBlockedBy({ repo: "harmoniqs/a", number: 1 }, 2)).toThrow(/403/); @@ -357,11 +548,12 @@ describe("amico spec slices — the verb", () => { it("files through the injected layer; second run files nothing", async () => { const gh = new FakeGh(); gh.seed("harmoniqs/amicode", 1820); + gh.seed("harmoniqs/amicode", 1740); const r1 = await specVerb(["slices", REAL_SPEC, "--prd", "harmoniqs/amicode#1820"], { slicesGh: gh }); expect(r1.code).toBe(0); - expect(json(r1)).toMatchObject({ created_issues: 50, created_edges: 53, created_sub_issues: 50 }); + expect(json(r1)).toMatchObject({ created_issues: 49, adopted_issues: 1, edited_bodies: 1, created_edges: 53, created_sub_issues: 50 }); const r2 = await specVerb(["slices", REAL_SPEC, "--prd=harmoniqs/amicode#1820"], { slicesGh: gh }); - expect(json(r2)).toMatchObject({ created_issues: 0, created_edges: 0, created_sub_issues: 0 }); + expect(json(r2)).toMatchObject({ created_issues: 0, edited_bodies: 0, created_edges: 0, created_sub_issues: 0 }); }); it("a malformed table is exit 65 with the named error, and no gh calls", async () => { diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index cf31e78cb..405ff657a 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -13,22 +13,22 @@ "packages/app/package.json": "3c9bf3d487052d4834b7fd3ce93fcf6b7fc358de92b8be4b0e84fc424a971f74", "packages/app/playwright.config.ts": "b7dae0b39248b4ddf7cba829af96ac7fe3cc919add9711ecb5292d9bd52e1805", "packages/core/schema.json": "f23c01f0670b96f80db5bc3d8e326f311143087d290064bb6b4805d7201d5818", - "packages/opencode/package.json": "01d9338ee64108d0bd6af80b971fa2da4c7bf63e7efae59cc97cd58a9cfce55c", + "packages/opencode/package.json": "b7e1946bec2d72302646fa7128e3686a73723bab9d5f0812984e5fda16e376d3", "packages/opencode/pasqal-live-test.mjs": "2ff0edf222ce29660ef8d901c0b6f10951943ef01824d2d264339f8d720206f1", "packages/opencode/pasqal-live-test.sh": "0bb0089eb413956f0252939f933a139c2f11520466887ad91fcae362f1698e41", "packages/session-ui/bunfig.toml": "468114e9b52c3cf926d2385c12d93385992cd70c97ea9e32ed22154617b7799d", "packages/session-ui/happydom.ts": "d81d6b14e988f058fa502c841df34b55307c57fc94a26fb4f8a5deb6aa0d4834", - "packages/session-ui/package.json": "f354c8675067dc8dd1493924cd6c8f561fb838ff97ae6cbd7e024868cbf0e0e8", + "packages/session-ui/package.json": "e45791b38522223cab314dad535a960bc8d2dc5d8752a1d9d67677b417369339", "packages/session-ui/preload-worker-mock.ts": "b53280029a042575734d7bfdf27713f6c5cd6a6665599dc429d839a3e3372bd1", - "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", + "packages/ui/package.json": "40b60d6bcb07157d998c431f42d2732d1dd96bcf1e0c1d1514f39428f5b82116", "packages/app/public/amico.svg": "7c64b145bdd063bacd53318336a423f0654ec702de2b4e0c4a4a384694052804", "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", "packages/app/src/app-desktop-commands.test.ts": "1d2057f238dc14e4407ff5c4e53afe091784790bc0fd0dbfde0eacd91e2bd968", - "packages/app/src/app.tsx": "6fe2c1136330b0b76beb10b15c0ca9df2c62877c98ba3eb4cb91844125916a82", + "packages/app/src/app.tsx": "c475ffe441d06e0392e6d5b436e564e67ce5eb17cd17ecc4d3afed6d2fc0972e", "packages/app/src/design-polish.css": "abe7515fe2ffdc090a51302f910d4b0873fb741f1534bd5196ddecc71fc7b714", "packages/app/src/desktop-menu.ts": "350e0e9f2e4294bfe1b1f357b3cd2ea0908b14d20ca1e79fda2305db1958fb24", - "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", - "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", + "packages/app/src/entry.tsx": "080b3c11cdd61dfaf4bea8132970a1a300b77b0e8fc53065e42d5ccd75eaed01", + "packages/app/src/index.css": "727656c5e7ff7d7c7ff4fc95a3ec7c9148a34f1f974a58a297285e870519be5d", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", "packages/app/test-browser/empty-workspace-landing.test.ts": "83d19ecf5da37c234dbde1a004432865204d7f412a335c4e470a07e73d9bcda4", "packages/app/test-browser/keybind-migration.test.ts": "192418880d432ff62d15fd80f6250ece6233e43d0dbe11d5a4fbb5f4d9e45a06", @@ -108,7 +108,10 @@ "packages/app/src/components/amicode-default-model.test.ts": "941750d074706bd5f4e433c9737b19ebc0ac0f9ba9cd1faaa599a24b22126e05", "packages/app/src/components/amicode-default-model.tsx": "16a2de1feb12e07416fe5fbb25879391f912ee0909b553c868c1b531f2ed1f2d", "packages/app/src/components/amicode-defaults-capsule.tsx": "94dc081bc47abb7e50566fcd8e9e53124f9d7632d61f93eff4f1627e0c2efb2d", - "packages/app/src/components/amicode-file-ref-bridge.tsx": "bd5c295080ccd80fe89208008c02db209ba301e431f61bc4432d3c8f2a72401f", + "packages/app/src/components/amicode-file-ref-bridge.tsx": "3371f36847c8a8ad4dcea9d925a4671ed7894fe1e9c083202ec53c746b5fcfe7", + "packages/app/src/components/amicode-local-image-loader.test.ts": "5629033ad71dec1c3d0b41aecc28d6529a619129147a844a063996d0c860d4e3", + "packages/app/src/components/amicode-local-image-loader.ts": "95c2833c9628a2ed79fe13d151699b3a3b310fae44e91672d839553d4466e418", + "packages/app/src/components/concise-mode-toggle.tsx": "3eff0c5557143500a27d828a08d1efe09a8c99aac9b6e2f9950bfe70c76162a1", "packages/app/src/components/connection-banner.tsx": "82194291181165ed7b1b1b3a6801a1bdcd2c2b7cdd733c28486bd69f86477a24", "packages/app/src/components/dialog-command-palette-v2.css": "85984957adaf15efcc0828310f3a0b438cedaaba776a9fec2eddb3ad83487fa2", "packages/app/src/components/dialog-command-palette-v2.tsx": "e12da3e5e9e503089dd9139b20e04f6b566cf74fa445f23a2e193f5883f7c5f3", @@ -121,6 +124,13 @@ "packages/app/src/components/dialog-release-notes.tsx": "0ea5b049786787643a7f6b54d4ccf84c4f8ffa4e8272426c0d32f60d445b6890", "packages/app/src/components/dialog-select-directory-v2.css": "2e2f18abdca77f9fe85da157c40f288f3b1641ca92845208749e16455fd58637", "packages/app/src/components/dialog-select-file.tsx": "e6278ee35dd27d203e00ad73f70c709c44a6c06f81ffbd931edfaa0c986b9e03", + "packages/app/src/components/dialog-select-model-reachability-source.ts": "ecd6c5e5f1df6d9e71cc87483d87870acb5c42140d580262c9dc76a8bd8453bf", + "packages/app/src/components/dialog-select-model-reachability.test.ts": "03e4a5e72a03f2c7c05deed3eb008a7f832af7e9beb10d2889c9fe92061e3346", + "packages/app/src/components/dialog-select-model-reachability.ts": "479403f9e2c799542e89fa0a28d4271684c48deccf02bd1e7c2caed9482e1602", + "packages/app/src/components/dialog-select-model-shortlist-source.ts": "3de6605f0e950a0af9e01297f492b14940fb1ef2456afb584215ab5e0a1455c1", + "packages/app/src/components/dialog-select-model-shortlist.test.ts": "57aa5604f7d9cba9f0f82103f382c988e9ea26a2cf104d14e89e56f0b1b0bc29", + "packages/app/src/components/dialog-select-model-shortlist.ts": "3412a56925a540c2d0843406f7018f689b990ff4a81402ee2ed35767590cfbcd", + "packages/app/src/components/dialog-select-model.tsx": "91f0d3fe3984211fef890363b3598d1f7e8c83b5c821004046d0466e7c6f3623", "packages/app/src/components/engine-state-signal.ts": "2726628b85d4bc58bf6b3bd3045fc4239ad384deaba14fc8f46b818741549a5e", "packages/app/src/components/engine-toggle-utils.test.ts": "bf53ca7a6fac3696f8d9aac032ceee5e35fb3b7e3dfaef3f9afb1fa0ff3135bb", "packages/app/src/components/engine-toggle-utils.ts": "bab8a7ddef9f49d4fc243477b7206feb741afdf5ec154a2d70a9f31c814f8c5d", @@ -129,12 +139,24 @@ "packages/app/src/components/harness-switch.test.ts": "db263ece42911e756fb8b6e88697a6bbbd0d1317decd1e63b027d9fe8c69b405", "packages/app/src/components/harness-switch.ts": "b4cb2b2241d71c477be71285fe6041d4b0d604a26d74c30dc5239694211a1f90", "packages/app/src/components/help-button.tsx": "780d533f776a784660cfed072a131198bc6fcc8e8fe332e8e1a220d516c9684e", + "packages/app/src/components/model-arrivals-source.ts": "65067c6243a6f3f14ba7e52822c588dfea66c3190c5ac393b73d9a58c399dcb1", + "packages/app/src/components/model-arrivals.test.ts": "185b0cc11a04e17e2f61ae43761543481a989191818cafb96a0256526ed3ba5f", + "packages/app/src/components/model-arrivals.ts": "88b52ad96ed5225748b4c402621749e276c26f45703efb4c36d2e969c6ca4edc", + "packages/app/src/components/model-dead-pick-warning.tsx": "51a3b761437e7cf0d33d49cf06ffa7be20c59cbdfb073637a5d7945e67840c14", + "packages/app/src/components/model-dead-pick.test.ts": "109cc9c5c89b645e8a085e28142aa3b6da77bcdb7e690b3af3bcbad7b41c99f4", + "packages/app/src/components/model-dead-pick.ts": "a85d9f039d0f9de66e3e59bdaba5f8b39b620a3a4d396ac845b8b89f024b5bca", + "packages/app/src/components/model-picker-row.test.tsx": "ca8e6715c2536f590047b27964ff808a018c8fd6f9ad660a01dac1b12cf95b4f", + "packages/app/src/components/model-picker-row.tsx": "da2256638a837c75e9e4756b19dbbbb131b8304c4cbb106ccee50fa1f4e74c45", + "packages/app/src/components/model-row-meta.test.ts": "51c5e8b9c4ef3f8e2616e63c8cbc16ae055dedc5fec7c24280ed28a3712938ad", + "packages/app/src/components/model-row-meta.ts": "7870b265f4ca9b08505ae6f1b0afb66ca002fe05f67c3b8194417a45f0b170cd", + "packages/app/src/components/model-tooltip.tsx": "c402abe9674806c9dd308589d7acb96dfd9c400299e650a94fcc44a165e920e2", "packages/app/src/components/new-session-machine-picker.ts": "8c7689f79c7610e32fef478320870d1468fa17a3a02463e2cbe058a487fb4769", "packages/app/src/components/pane-bridge.tsx": "756b3306b51c963fac6778716f6c6c4626f0f3bf75c93b7cb4cab58ea0bf3d4e", "packages/app/src/components/profile-popover.tsx": "eff742e90544455f13234a9351bb1b82189663cba2cd3172bdcea1a8a37284e8", "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "946b718c2164da394602f26e4d9919d752da0d0b9bee714f345ba16c274542da", - "packages/app/src/components/prompt-input-v2.tsx": "a37d61cd44e630100758c3de3dbf8cf7e0d4d0e85dc54e6bb7bf38290ece4468", - "packages/app/src/components/prompt-input.tsx": "c127f81e2c0de4bdbc21a349473df39c623d8d7e2df8df0c03ea60bbef101a25", + "packages/app/src/components/prompt-input-resize-structure.test.ts": "38244b103702afc4b45cd40176f338c2a632d64b8ee9a719ee2073b6d7aaf062", + "packages/app/src/components/prompt-input-v2.tsx": "f0b637e46737e3c7345957a0167c878c8e9870a15bb850057665a4444ea91fe8", + "packages/app/src/components/prompt-input.tsx": "e3bfbadfd0a049aa2351bb6ef413708a56e66881fdcc052d43a6cd82563c6c03", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", "packages/app/src/components/question-inbox.tsx": "5ce03a30b7110092ddffccf3bf38e6c1b1a79624f378c819be7019725c96478a", "packages/app/src/components/remote-create-arm.test.ts": "388132209e16b6f00d79ac7c700f8c2bfb5f80343eee34cee73451b32842d3f2", @@ -151,7 +173,7 @@ "packages/app/src/components/settings-providers.tsx": "c86c9bf4a6f6e198dc7b0995a69d5b76e851144987bbe601802b1b0120ee4ac4", "packages/app/src/components/solver-switch-banner.tsx": "ec7edeec6dc05a4a06b39241b4f3a8e7534da97722dd8ec6f26ff782025802e9", "packages/app/src/components/split-frame.tsx": "44c53737f72027b461097c03df48a4f411d04a82d02b1fce4901813c73dfcc72", - "packages/app/src/components/status-popover-body.tsx": "2b6aa39a4358bbce7b6836b6a719be8eb24db96cd603bc0b4fb9fb0365413fed", + "packages/app/src/components/status-popover-body.tsx": "8efb35f2cf50f61a6440696056249b1fbc68d49b458dec95c357721bd4a77b70", "packages/app/src/components/status-popover-indicator.test.ts": "e3530caa3a46b822603a8d0a753550605951c2cc5e95c60c46636935f2398a7a", "packages/app/src/components/status-popover-indicator.ts": "d705c258e3404567fa5ae6d8ce9c27010832f12fdd614cef21f0297154e7faae", "packages/app/src/components/status-popover-model.test.ts": "10634035bab0f31de24564e0d6c8413a0f7434fb2c08ac3ef722981a69dd08e7", @@ -177,6 +199,8 @@ "packages/app/src/components/windows-app-menu.tsx": "262d085facace692084d79763a6bd8eba6c5ceb53b952a134e12c7e8a8a12946", "packages/app/src/components/workbench-panel.tsx": "d16f477044098842a214a2e16f9be719e7688a04bcb575889ca544988f23c690", "packages/app/src/context/command.tsx": "388d6cb12b6dc43ecb0827e3fb918370cb9d16d96161a5990dec10fff05af172", + "packages/app/src/context/concise-mode.test.ts": "534258a1d82d66bd8b482bb3a8ca6a24d508328505abd0f7f52e1b75cdf57fa2", + "packages/app/src/context/concise-mode.ts": "b03ac887222543e590d31781c944e69aca97bff32a661751dd817d46bf4ead64", "packages/app/src/context/directory-sync.test.ts": "c03c7bcd95ac47b3de5e1e9719b6eccdb3569063026fe993b448453c818c7b48", "packages/app/src/context/directory-sync.ts": "d5734fc1a32cd5cf1c00fa2cda8c5adc80b4211baf12e3d6715b69436130cc6c", "packages/app/src/context/fleet-reseed-scheduler.test.ts": "a7f3fd7f861d2fe9d89e2eb20d125e61f6aa6180bb7d2ddb5422113a1beeba17", @@ -190,11 +214,15 @@ "packages/app/src/context/layout-side-panel-tabs.ts": "e8dec07b2b5a5ebe68a76c98d688afa1360a4e31741a10bb49228afb8a7f3777", "packages/app/src/context/layout-tabs.test.ts": "524327496e735379bb3425e78cbeb6cefa92e30296ddc44776eb8e99d644925d", "packages/app/src/context/layout-tabs.ts": "741506ce165f68cdb0b8f2931a2dad3e26c05880f9286c3daf04ec979bac21c5", - "packages/app/src/context/layout.tsx": "7767c8b5f64efd63390cbe99128686bec2f1da7e8f2b21530df4c3fd67d171bb", + "packages/app/src/context/layout.tsx": "9bb24aa18442cf0dba1dc749f49060de4d45e50bcd5b0e148325e8e4611fa2ca", "packages/app/src/context/local-agent.test.ts": "a5a9d60bb4401d409218cc247c1cc06ede8b7878ddf01dc6080328f54eb9cade", "packages/app/src/context/local-agent.ts": "0aab67e695dc3bb45a733ac0df80a0a5e14cfe29b2375b6dc3cd07fbccea33e2", + "packages/app/src/context/local-reseed.test.ts": "b060240fc15ed9464cd2f03b942d8ee0a27e43712791e8425a55417240738fc6", + "packages/app/src/context/local-reseed.ts": "8959700ce8143a478158644c5ff41e690949b21905ff71aa5b6a014a6decb085", "packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", - "packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535", + "packages/app/src/context/models-favorites.test.ts": "6336bc4dd46a0492e393110ee7aab6ee58f152982ec657e17ad9f750d22483f8", + "packages/app/src/context/models-favorites.ts": "60d4e6889ca9143432417f624ddee14047f14bcfd1325fca2f491fb19803b1a2", + "packages/app/src/context/models.tsx": "39821854ea95753c583c05f9b911a0898ed8ef7b6da49613a19aa3ee39404707", "packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da", "packages/app/src/context/question-inbox.test.ts": "d4e2c666194fe6463d1a1da1901f2aef3b6a17eec00394a2b020d7ce698d4c92", "packages/app/src/context/question-inbox.ts": "f42a2490670e44578f88d626bc0db0a0205fa2b5ec7cd719ba0544915f3a6179", @@ -204,7 +232,7 @@ "packages/app/src/context/server-session-orphan-part.test.ts": "1d635374c0bfff25a01825061aabac7334e5cdaedca2dab9f3d9467ff543caea", "packages/app/src/context/server-session.test.ts": "b332098635885211bccaec0d7da8ca6b60696b04bd3f89ba3fcd97cd4c881856", "packages/app/src/context/server-session.ts": "ad486fc5d36f93f5f53d51b70fdf191a3008b67f2b280dfc58b64e6d8d20982d", - "packages/app/src/context/server-sync.tsx": "55ac830560ddd947b5d58a9c496a4e94ffc63fd7241c74ba9b843aea724b74ce", + "packages/app/src/context/server-sync.tsx": "a8dad0dad76a402e6c4f912bba1d0a2018bf8885f30ac8fcf03b840f3bebc29e", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", "packages/app/src/context/session-mirror.test.ts": "65e6d6474e4e67f2da194047140c8c8aac04fe0cc9926441468e53fabc48db96", "packages/app/src/context/session-mirror.ts": "b98de060f2b06e6f9a48b1f69ed408f12086c626d90c7dd22029a4ee732260e2", @@ -238,7 +266,7 @@ "packages/app/src/i18n/compaction-failure.test.ts": "a4a3765beb238a2c157c07c9c8ede8418b9febb814d8d1e3a5b2d8868cbd80d9", "packages/app/src/i18n/da.ts": "db685cb94f22ace194850efe9bdc02c530777a4583dc9eb9d00b97002b0a0968", "packages/app/src/i18n/de.ts": "c440ad7543321cae1425e0c2fad92ff73963f21672fbf31176831933573bf103", - "packages/app/src/i18n/en.ts": "d073d90198297bcc33ad5da1975a3829250b20a1e18fec35eb65809c35fca973", + "packages/app/src/i18n/en.ts": "50c3be0346b9d989f43de76e39319ab8192bc91e065e2af40cbaf3b0ffc27474", "packages/app/src/i18n/es.ts": "8cdec3592cddf0b91d7fd59002c81c4e8973c5bf6573e065df64d48c42e1802f", "packages/app/src/i18n/fr.ts": "b9f646352189054b6918bfbac670579e76418c3148eb7e5afca9d71d97e7f2b7", "packages/app/src/i18n/ja.ts": "27ffc41a0fd8dc4b2e814a45c55fad4726ff1c7a85c884cf6af95f8397f17cbf", @@ -267,10 +295,10 @@ "packages/app/src/pages/layout.tsx": "90306f1691cf22b35627e063ce6401ea0ed2f98e12db022861f403de2d0f2d08", "packages/app/src/pages/new-session-landing.test.ts": "95e2780456a653a71e6f66b4302ae546e308a29e6b8ad9e84442938a3a226905", "packages/app/src/pages/new-session-landing.ts": "528d27cd5d441b521674328fb7812fb2acf77233e9e436d081c98ce611e8b268", - "packages/app/src/pages/new-session.tsx": "c060bbb15569b6da2f67146e6af0879ccb5444345df03dbc2f9b39cb98b9db78", + "packages/app/src/pages/new-session.tsx": "e69db43dfd9f29a3b8920bf63654f40772bfb0ab6d24029c7903a754c94e8571", "packages/app/src/pages/overlay-content-hash.test.ts": "aa208098213522dcde42434408afe9c8641fad663f44a58c316922dbd0da2f5c", "packages/app/src/pages/research-surfaces-absence.test.ts": "9944a8ec5492500eb82548d03b24ea7fb8ccc9c2ae5de14a4d18ddb309378746", - "packages/app/src/pages/session.tsx": "7918f8bed4b1bd0932e5e6681546217fa24e5b6e81667163ef4b63439516328f", + "packages/app/src/pages/session.tsx": "d0c9474b9ac19db10c14b2dfe723f31243c0c8bbbba05e76a1bd226c0d58d6e3", "packages/app/src/utils/amicode-bridge.ts": "3ec87c24c0384d312f7eb644807d9217a53b1b4b97f7fffadab1cc1595603f3b", "packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444", "packages/app/src/utils/amicode-bug-report.ts": "d55d9cecfe17719c0d4a0ca498345c7eff6fca1de6b645e4bc3fecb40f2f4645", @@ -299,7 +327,7 @@ "packages/app/src/utils/retry-import.ts": "591bfd3e28f8afa9c4d9f0ca118d20e875cb1dce574c0ae25615994c4fe57a99", "packages/app/src/utils/serialize-session.test.ts": "f8fe318f195dca251eae3f4899f4a5881c3523522f59e02fef208588391f4048", "packages/app/src/utils/serialize-session.ts": "c303c678500cdb64c7aa6928a0bd984c441a36705fe8152368a0e575cbcb459f", - "packages/app/src/utils/server-compat.ts": "01413e13b87485770eb3dbec0b882aec4c8247dafc4dc74b7b4669fa5b20cf1a", + "packages/app/src/utils/server-compat.ts": "1cccb1aabde1bce6ee382c33db0dea9273a9519747b805b771dec060d3071b39", "packages/app/src/utils/session-message.ts": "96f8f3dca62f70215ac2dccbc8d27cd5b247af3ecea1ac5d34a74ffc8af5d4d3", "packages/app/src/utils/start-prompt.ts": "809d8ad0ab6de76a97503f8368ade25f771431be3f92eb8f039a2c65244ab20f", "packages/app/src/utils/vscode-explorer-icon-theme.test.ts": "351deb0ff77e62d2585858105aaf34ab621298511798fcb626abee97bd644fe6", @@ -352,12 +380,13 @@ "packages/opencode/src/control-plane/workspace.ts": "ab986282d1bd15b7f4473e33766884e17c97684ef0778cd8d84fd173604175cc", "packages/opencode/src/effect/runner.ts": "b660b5b22df810b16293c23c6d832e5e8dcfb12b9a8a858c875d2c051c855b81", "packages/opencode/src/mcp/browser.ts": "ecdda42cbd55a97b8d14050aa15c6e6450a1d63b0a641c8c8e5b81e50f957c87", - "packages/opencode/src/mcp/index.ts": "da45165e6791a5b30c8962c7fd0ea391bb8e1931cc2357c6b90e9fd090cdeb0d", + "packages/opencode/src/mcp/index.ts": "0b6cbd751a0e57c6f103f1d22b228288e537180f5989a60c33ae8247c9ebe9e3", "packages/opencode/src/permission/index.ts": "feb352aacc8c110ad930b53319cd99c4b7cee015bd2bf9289f3eb24c608b01e8", "packages/opencode/src/project/instance-context.ts": "65a35792e50e96a50ea14cd9f928045c0e2fc15eb49506f5bbceee38c4af1457", "packages/opencode/src/project/instance-store.ts": "c22d3e2499e63dd8d9395d58189df75444d306774df833e746f22e327edcf2ab", "packages/opencode/src/provider/auth.ts": "b0ab1322c1039e27176bf9e3500603eae27506065ac141a69710220f3361f6b8", - "packages/opencode/src/provider/error.ts": "d72310be26cfae5c2943f9939a6461e848532a5646ff405d628e9a50fc1ebe91", + "packages/opencode/src/provider/error.ts": "871df37ff31d180e99e9dfc49bfac773f8adb5f9929bba9f1a3053f572723620", + "packages/opencode/src/provider/probe.ts": "6f5b7f3a201a67b66c387411f4c893c7e91ca74a70fdcf7b220f1a2ff511adc7", "packages/opencode/src/provider/provider.ts": "b89bc0ca0a12ea0cfaf5c956a4b731a0397d4c407e787a16cdd66fb7f54ee476", "packages/opencode/src/provider/transform.ts": "70c7492d4a23a30ebc340a5605702b1602960f4b412e18557534e02962c137b8", "packages/opencode/src/question/index.ts": "8317348c2ca27dd9e4f7d18fdc126a4d355c20495c1b86668430ad477a68e9b3", @@ -405,6 +434,7 @@ "packages/opencode/test/effect/runner.test.ts": "6be02ad6a7d173642100a9e17b323410bad7ece842a00018d2170b91d4894c5a", "packages/opencode/test/fixture/fixture.ts": "357bdaa06c6233a91069db20cc5f1cbb2d6eafa3bc7ba3e89b019859d195d6ef", "packages/opencode/test/lib/cli-process.ts": "110c43d247930e834ffffb9068682c5ab2e1b95460ec1ac3d0a75cafbdee60ff", + "packages/opencode/test/mcp/remove-static-config.test.ts": "e69002eeaa099ab78b6bf5aa4a84da69a2cb20911da8237e4559fa2a32615060", "packages/opencode/test/permission/permission-store.test.ts": "0a105e3a1d5f238fd641d30f30d1d38ce49ae3dcaba3deaa445b505e4b6c1921", "packages/opencode/test/project/worktree-rename.test.ts": "3ec168c9f8595249b75832895a251483ec1f3641ce44ad0bd5a664b5bbd6dad1", "packages/opencode/test/provider/combine-abort-signals.test.ts": "0a997303e717184e3e237e4b978b64a34ab8022724c47245b25f32e90738d205", @@ -442,7 +472,7 @@ "packages/opencode/test/server/httpapi-file.test.ts": "d3386129bc81467ecbcc57a3861f53767ee56ac41b4ff6ad58d8b168fb3b3373", "packages/opencode/test/server/httpapi-global-event.test.ts": "8a78dc59edf48eb3cf6d1c09448f7dfe5ceb138e1bd6125949ebc88976c903f0", "packages/opencode/test/server/httpapi-instance-route-auth.test.ts": "4b73a89d2431c02bc3b665d93f9002914e51eb1add979dc1867efbcbc0c36e25", - "packages/opencode/test/server/httpapi-provider.test.ts": "9e8b3e085b478e3f99b689ffff600e10c0b4b50b3cabdbd749f88dae19bf5904", + "packages/opencode/test/server/httpapi-provider.test.ts": "0bfaf897433a68de3707c8ca731af5b4e3470589d094c07fc6c5177f9939d1af", "packages/opencode/test/server/httpapi-session.test.ts": "eda6fd763ac4c17891cedaa4ccd49597c03e7227fcfdcf0f94a1b4545ba623ed", "packages/opencode/test/server/httpapi-ui.test.ts": "d029e959cfd9347fff4cf17c5686dd125b0bd3a41afb3408a23b31418f12e3b0", "packages/opencode/test/server/pasqal-secret.test.ts": "54be8c666160dad22cd37bbf041f5bd40aec120271818f3c56c94107d987b9fb", @@ -493,24 +523,29 @@ "packages/session-ui/src/components/build-trace.test.ts": "6259c529fa667a3514bfee1c771e8f623bcb8c69e37ceb17ea541aa42e207451", "packages/session-ui/src/components/build-trace.ts": "153e3182c3a37e4aca9c88bf0e2052b0ff4ec117a60d74d2c81567ab91180066", "packages/session-ui/src/components/group-parts.test.ts": "a31ce19009e1d62e75e83b682190a4613da0eed334420409ed73a9e21834ae84", + "packages/session-ui/src/components/image-lightbox.tsx": "e03904142307429b9f48275d0e1d6ec4b9fcbbf24ea47420bc4de89db78e9e89", "packages/session-ui/src/components/line-comment-styles.ts": "2077836c13bd3c3f1c39c67722119e448a2dfa123377f94bfd3676634e955d00", "packages/session-ui/src/components/markdown-file-refs.test.ts": "2172d17a0ee5e49636c8bd323f4fb797a7f41e469d0098864b82d374f0c10144", "packages/session-ui/src/components/markdown-file-refs.ts": "417417f3660fe18f04a13ca4765b155b5479d7ce41b3638a57ba1abaa5830054", + "packages/session-ui/src/components/markdown-local-images.test.ts": "f344e43f4272841e867909c31955655be9b6dbfd81c986d8c6910b3ae01f6d33", + "packages/session-ui/src/components/markdown-local-images.ts": "566cb252bed28e300a93efaefd0f1bf3d893c77d2bccfa35f1fc6b961341702c", "packages/session-ui/src/components/markdown-stream.test.ts": "b73b1a15ffa2cfea5358b2d6313ce17d26a5a11302b0e3b7d823cde0c98cbc83", "packages/session-ui/src/components/markdown-stream.ts": "50e38abd5d4ff919564655799678be10463dec58502fc5487c1c97a9a8db5399", - "packages/session-ui/src/components/markdown.css": "24e7c19218620386647656a5d27e116cdeca911f1916c5d5b8609edca11ff165", - "packages/session-ui/src/components/markdown.tsx": "00a2248ca301e39eb5f2a9b78abfe9d11068ea5daa970516e1f8700c44878c48", - "packages/session-ui/src/components/message-part-css.test.ts": "bc164695d0195684d646ebf490ba7bb4ed72adcf2bf3d01b00de47f4f03bcd4f", + "packages/session-ui/src/components/markdown.css": "e5654bf01501ab93bcad54b488b460d6ca283da9875978557f853106738ec830", + "packages/session-ui/src/components/markdown.tsx": "9eba82666012819518e4e9145e75373e3a6d1d0d33738da639c8e5be117c43c5", + "packages/session-ui/src/components/message-part-css.test.ts": "ce4bac108c6510197358023cc5f79d2f265313da2f0e8f70672919e72e576b10", "packages/session-ui/src/components/message-part-groups.ts": "37be069bf46e926fcde83d3744ee706911ff002e6b532e279290d841aeee96da", "packages/session-ui/src/components/message-part-skill.test.ts": "453bdcb25e4df91ad1004635b8766070a60421355adf18ee53f43cd2fa7d0486", "packages/session-ui/src/components/message-part-skill.ts": "d65a15dc39b8c0dece13aebe5e308426ae2dd758b07740251525ba9257654cf7", "packages/session-ui/src/components/message-part-task.test.ts": "f5896e3f5eeeed2dcc55d690be56483902ecc06f047cb44cecb2dad31ee6c405", "packages/session-ui/src/components/message-part-task.ts": "4276091c6f8ea2c31819c72b82747871e4548b3d402b7d1b7b06603ae69b2d5a", "packages/session-ui/src/components/message-part-text.ts": "e38d68224f1743ccc5e0e0a8023e11977e3b761ec67dd5364cde9cb34e97ea4b", + "packages/session-ui/src/components/message-part-tool-status.test.ts": "7f536848fe91bf9f07246774f1357791bcc45037483ec4c0697fbb766aa027ba", + "packages/session-ui/src/components/message-part-tool-status.ts": "b68c636e71027f79e6a42280a450611922118e4a8be99b03b5a4d10257da50b0", "packages/session-ui/src/components/message-part-user.ts": "1f2c8cb4efff5274863a133f369a7397cac804678e953740f356ae03b71d475c", - "packages/session-ui/src/components/message-part.css": "4b2950a029426fd90115e544a96847098e7e0426e9560187769e488b8cf8be37", + "packages/session-ui/src/components/message-part.css": "4eeb80f648de4655f882474256e6d34f965d01461079887bef3c7d699040d031", "packages/session-ui/src/components/message-part.test.ts": "7c66430837fa3d2607657e57e35720ae68dc5e84662e12826713a938301eeb7b", - "packages/session-ui/src/components/message-part.tsx": "750e65d7e79fcdb16db9e691a4d5aa2b406cc33c97871a8a2189c26f96d7d044", + "packages/session-ui/src/components/message-part.tsx": "8ce432505e980312bf0f729821d852d322c0e1ddc9b1df0fba377ea8fbfa943f", "packages/session-ui/src/components/session-retry.tsx": "2cb1513d73275a269894f601e8c1d29bc790b14927134fe36177f2b371888721", "packages/session-ui/src/components/session-review.css": "ec8c5b359dd4b7ff80d7fad8906359678adade9ce62c9d74aedc000b646db341", "packages/session-ui/src/components/session-review.test.ts": "41515bba238b4e60ce3d80e206013e9b43e3b0d0a51be45796028dbae47c99ae", @@ -525,7 +560,8 @@ "packages/session-ui/src/components/tool-status-title.tsx": "ae4561b58f55985d2294a352802e723b18943ac9a6551d97f1c4a6cc95368c8a", "packages/session-ui/src/context/data.tsx": "7b840b5bf6929445d21da2cbd8e8006316c6950a09d559ea77e8c397e728cd99", "packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee", - "packages/session-ui/src/util/clipboard.ts": "c37ba53914714ed04135e5a254e8ea90186f097d7470fa0873a83de82f3fef2d", + "packages/session-ui/src/util/clipboard.test.ts": "16dc2c7bc04f00134841e6e1e6b503f519a14aa41e4cb83cb1d63002a770992c", + "packages/session-ui/src/util/clipboard.ts": "89dc04d0faec989f48c44d82107a5b3d024f078f6146998f2392672b1647131e", "packages/ui/src/amicode/activity-dot.tsx": "fc3c9e620cdd204e930f0d53a0a44456e0c886679174c91f0efbf9707daa7bc6", "packages/ui/src/amicode/activity.test.ts": "1674ca5138780242767b0eb42fc444a9b8ce7868ac119117dd1487b7209180b2", "packages/ui/src/amicode/activity.ts": "a2c9b4fe27175a400ea0bcfeb433dfcb572cd793766bf0fd232c741feef5cfbf", @@ -548,7 +584,8 @@ "packages/ui/src/amicode/brain-data.ts": "f23483cda811e148748d4f47435c6d01c03d52e9a65ad74cae2c0ea30ce0b8dc", "packages/ui/src/amicode/brain-engine.test.ts": "3b244fbe15ac87529655acd688da9181af53a1dfeb3f9f87e5f77000db3aada6", "packages/ui/src/amicode/brain-engine.ts": "500b03cd43d4cdbd76a575f4517a4aaac788167e1d41a396d6dc6bce3cdfb8dc", - "packages/ui/src/amicode/brain-ref.ts": "9d8aaa8b24fb0b74acc0c4cdafc265c1808c28c95d4ac9b815fd2e54ea89f46d", + "packages/ui/src/amicode/brain-ref.test.ts": "d1ea44c003446d1824543bc56884e0d218291417bda9ea7835a21d8547c256f1", + "packages/ui/src/amicode/brain-ref.ts": "0f8e866933ec3d161973f5e62acdfd51b0320f3ad5be4c487685c469467901c4", "packages/ui/src/amicode/bridge.ts": "e7b93a15060a0cb1ab0be2b367f73e926d74b990a8af917a9966b451ee303b5b", "packages/ui/src/amicode/card.stories.tsx": "b1591abb4af426b0eb08588ea1585336a392d755b5d308cb31f17cb65acf5a3e", "packages/ui/src/amicode/card.tsx": "1f462017d9113082bb2e802e3d812b25cf5ee9b1bd848559e0533de4d382517f", @@ -583,8 +620,8 @@ "packages/ui/src/amicode/problem.test.ts": "3b7d212cc5a11855c0606189c007f2bbc3957c6b2ee4e1642fc95242f3dd94bd", "packages/ui/src/amicode/problem.ts": "186c1615f387ce930f61b2a8bcc41a11c7d6979c3a398e04be9a3939a6931d5b", "packages/ui/src/amicode/project-type-icons.test.ts": "1db755e2818a5e97d018e7133ae329b9ce1ff63fc347a06bcac7b9f4d0784969", - "packages/ui/src/amicode/rail-gate.test.ts": "7cf0ff5add76f27eb4022224a0f18532a7bc419dd5368788e7a0667f9913dcae", - "packages/ui/src/amicode/rail-gate.ts": "c24f99d8285a5484863078acedc5ed36d8de67084c2baaf2bb2f7b6d87e657cb", + "packages/ui/src/amicode/rail-gate.test.ts": "01b5ae4907ab2ccb8d5ce3284747f5055832de1221eabb88285eb699004e991b", + "packages/ui/src/amicode/rail-gate.ts": "9941b9739bd1150a460cb685f787f9daa6a2efa32aee79d68fd6984ba0b90b47", "packages/ui/src/amicode/receipt-runs.test.ts": "2c25968b2ba9eaa7fe70ed4cf767aae88d3db9595e01d880215514fef7a4563f", "packages/ui/src/amicode/receipt-runs.ts": "4aa31624ea3d61ab352bfda7cd0da48f7d367f123cf7929f52f4da84629a190b", "packages/ui/src/amicode/receipt.test.ts": "7065899d0efd4b2b712bf412053d1b3071e6aee4be163f38a3e0a7dcdbbb45fa", @@ -695,8 +732,8 @@ "packages/ui/src/components/toast.css": "60c06eda9f0ccc5641c03c002a6080db99c00d797dbdf7511c789089efc1c546", "packages/ui/src/components/tooltip.css": "a615df9b86e64c1565384baa8054c2f80ebad11856d33f5e6f53902541eacef2", "packages/ui/src/context/dialog.tsx": "876f8a5dc26ebb98f1f5e9e528e7d0bd7aa79c0a107b62027dd9fe78ed2cc620", - "packages/ui/src/context/marked-math.test.ts": "0a53b2701d79ef16abe3132a514831ada60a535324991e4d72ee4e0ced9a0ae6", - "packages/ui/src/context/marked-parser.tsx": "2d8d0c2e5e28278f54296640d1e851fff4ef3b2902c7f70d02cdbcd82545bf39", + "packages/ui/src/context/marked-math.test.ts": "3706c4ec3c240e4fd58f3fbea5d1054b751e51269abd1806d0aba230d7a2f1d7", + "packages/ui/src/context/marked-parser.tsx": "692b5b5274b585fde3e2ee2c92370af7466a852e92be286078ca091b43b4c757", "packages/ui/src/hooks/create-auto-scroll.tsx": "4a8e9aa41f075b5e1815e647ab3a8a5333340d00e20ff7414f5e6c1f9a738337", "packages/ui/src/i18n/ar.ts": "8113d320fe4a5300f1bd70150ae1695d68cb783e1c6e2be43b8a429b7dbdb711", "packages/ui/src/i18n/br.ts": "cbde2da43ef820fe2664cb96cb973fd722156a6c8a3c5e16d18638bf014e5196", @@ -724,6 +761,11 @@ "packages/app/src/amicode/inspector/inspector-bridge.ts": "460dbf4509b4e56a8eacd6cac7adcb3a63c6cd3e417d851df5f3e3b259095d3e", "packages/app/src/amicode/inspector/inspector-context.tsx": "ac81b9e0e2a4035f85b6af68b7f0cefcb9f256a175b244ba13136f3a307c0c77", "packages/app/src/amicode/inspector/run-inspector.tsx": "63a6351fad12a8377550eb87f7602a712bae586fe482ff46cd847c8544fc6e8a", + "packages/app/src/amicode/whats-new/whats-new-model.test.ts": "fcc998d0b73b8d5ef30cad993fb3a803b130fea335040463a1425b5600d22d96", + "packages/app/src/amicode/whats-new/whats-new-model.ts": "8d73f0f32d7bc24c52a620a124d0b8ad661ee168c7771d8236c4b7c62616fdd0", + "packages/app/src/amicode/whats-new/whats-new-view.test.tsx": "3c795fe0ba6bf45fe40b0c5bca3cd3f96ebfb3c95cc3b18ae9f80237af987907", + "packages/app/src/amicode/whats-new/whats-new-view.tsx": "90b31bc029bafb088f3e1f0669e0a484fd90b6d314f70d2f77a4613121877c23", + "packages/app/src/amicode/whats-new/whats-new.tsx": "1b996377d7b67a8b1954f4b0711cf52619eb7f2552b175f2f76ed1e7b938a68d", "packages/app/src/components/prompt-input/attachments.ts": "c3c28df0d2f18378d39e17147a4c64e8ea01d63b97c39e1874ed9445ca671bb1", "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "15f76dca1a25d024128718e4099220b79ca029263410aab55502cf9caf9b2914", "packages/app/src/components/prompt-input/clipboard-bridge.ts": "4b5a1edf59d4c85268d6ff72aa9f9d66f82d4fdb887121fb978c09caf4c5be65", @@ -736,36 +778,53 @@ "packages/app/src/components/prompt-input/placeholder.test.ts": "d3cee5e2c758faf5fda626e270adad81129622bbc2bf2c335c2bffe1544165c3", "packages/app/src/components/prompt-input/placeholder.ts": "6b2af23a0588e7970cd0dd059fc60c3671c907a76ba3db660483a932f1dce79d", "packages/app/src/components/prompt-input/slash-popover.tsx": "a1800173edebcd419802796470f7270acaf0ef689a72f1b81a1cedfd8968337d", + "packages/app/src/components/prompt-input/submit-concise.test.ts": "5d76bad06ced24bda9e5be84f6a01577145bcf2ad6dfa55d2941344da2a3d935", "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "351f0f5a8cf7aff55c859cb82fbca388ef848831256b80f74ee52ab9cf5d07c6", - "packages/app/src/components/prompt-input/submit.ts": "cdb01cf2f2485fbbdb7ddc77dcdea99197d8c03bd302b8fe032bee8c15f525c3", + "packages/app/src/components/prompt-input/submit.ts": "42c2177bc7f36355d65cd363e5ea2780d16c960991b5347a8ceadaccf11b8b65", "packages/app/src/components/server/server-row.tsx": "caf3179cf0b34b00fcab6ed57a324ff1f90f571f604216c49164dc54bd9f12ab", "packages/app/src/components/session/context-warning-banner.tsx": "c4ceb3f9589ebd21cc66e21cef209552fc5fcff6cd825da5069c2259c8d33e50", "packages/app/src/components/session/index.ts": "f0d58fa3a336639566ec51a10b692649707f91a7b780c0b5c4dcf18642d030e8", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", - "packages/app/src/components/session/pdf-canvas-view.tsx": "591bac93a2e1e10a1b42404a29bcc5222ebbc5faf734c80556fbca35c90551dd", + "packages/app/src/components/session/pdf-canvas-view.tsx": "17c73d71799998741555191be71a8b8b1760ba35305c53a1077722fc80aaf666", + "packages/app/src/components/session/pdf-find-controller.ts": "0116c48bb5f463d9bfda938eb52d5ec52c49992a9cfde2ef3ed1051b0cb14b1c", + "packages/app/src/components/session/pdf-find-core.test.ts": "7c002a9e79bf10a167a643477fc2e9c0c3c8257db905c31ddc12cf9e15bab1cd", + "packages/app/src/components/session/pdf-find-core.ts": "9da5cd17d1140c0080f702e7ff7f74b7a771bcc1e910573a1af7021189761dac", + "packages/app/src/components/session/pdf-find-guard.ts": "61976e19ebae5d5bc17c87aea41332ba1cb650d0e0ef981e51c864a086d21292", + "packages/app/src/components/session/pdf-find-highlight.ts": "6299e9f68dc261d18100b5e3e96b42ea74e47db93ac49fe94ef567e5f6581454", + "packages/app/src/components/session/pdf-find-pill.tsx": "1e298a28d9b986a749ad15619623504be8126a4161af24ca674ffd978120505b", "packages/app/src/components/session/pdf-restore.test.ts": "4cd4cabbdc8063deb5fe95b5e637cf154ccdf864c5ff9f2f6833826bd69e4f90", "packages/app/src/components/session/pdf-restore.ts": "e5c92096b9302a452439babfc2732610a7e0d18e23793ce3bf6352fa32f91a36", + "packages/app/src/components/session/preview-draft-store.test.ts": "3985665fbbe2255dcc1ea683a4353c5d2e4c24e77e93e5981bb466698fe23c05", + "packages/app/src/components/session/preview-draft-store.ts": "28355fc9be787f81ddc0f052f96d51c283ab9a917b309ae05a29c1c98ffba7a3", + "packages/app/src/components/session/preview-draft-wiring.test.ts": "8d54de2788ab52e7ba02a90e606072602aa1de3411d2371148258e2a9d33b44a", "packages/app/src/components/session/preview-file-helpers.test.ts": "e0c1ccb40b48990c0c5b87ea62a35de60b860cd760067c7f1327cbb1d36584b1", "packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e", - "packages/app/src/components/session/preview-file-view.tsx": "ccb0399603e9e68da04d767b24be36d39e033354b25c2fe59f8a00007dbc7f1c", + "packages/app/src/components/session/preview-file-target.test.ts": "d9a341349d8922b0369f3fdf911cce35d96344962ad442267e9078106d266db3", + "packages/app/src/components/session/preview-file-target.ts": "11738d77fc1e86a4a968b65f3fb342f51792b6a2c2d57a08114197ff2e82a51b", + "packages/app/src/components/session/preview-file-view.tsx": "5781fdd4a3a812efbe0c635b6eabcd6468d2d0131a008bccbde751f9050dc12f", + "packages/app/src/components/session/preview-find-in-sidebar.test.ts": "68cdc2d9743b3c3a6a6c1fab6cbd362d27b4f7179cba973cba67d10f01b35970", "packages/app/src/components/session/preview-frontmatter-card.tsx": "00f439e3bf482673dfb06c0ce3464c0f51985093ed57b321cdebd38d452f2e5d", "packages/app/src/components/session/preview-frontmatter.test.ts": "93ec9f7f4c604d8ed8ee6009779c425bffecfca331a122a7a700b588a8ca3c0c", "packages/app/src/components/session/preview-frontmatter.ts": "036250edd8c2886ee8f8ac0723f2b9a41d363898006a53f47bc44b8194c5a3d3", "packages/app/src/components/session/preview-human-write-gate.ts": "4c4b1cf258a06b0012d722add0125ecb77a0f4e463dd95d193f21e5e73541098", + "packages/app/src/components/session/preview-latex-compile.test.ts": "bfd86b8e7ae2da8060aaabdb167ec8521983777040c7201a7bbb7d7e3c877ac6", + "packages/app/src/components/session/preview-latex-compile.ts": "b9388ca0ba698b023e73d823b6064428937fdacc4ce63f1682018f49387307b8", + "packages/app/src/components/session/preview-restore.test.ts": "99f4945ad4aa603d6245a82464bbba8b374eb169ec5a559fc1963d5a706ecf35", "packages/app/src/components/session/session-archived-fleet.test.ts": "f5d0e16dea2ab6e6cb44a6768b5ef1e772f8ccc39aeeb02571525ec44336ab57", "packages/app/src/components/session/session-chats-dropdown.test.ts": "72e2e797fe96a59ee46ae59c4a5cb34eae8d338a468977bd4e8f61cefd6abd49", - "packages/app/src/components/session/session-context-tab.tsx": "4062ef39e161f041bf1d7a300173c510cecd581ad8e1fbb4503961b7cd1a29f5", + "packages/app/src/components/session/session-context-tab.test.ts": "bb42b97a6f3966c0b1a626b1fecdd82c6dba26e65a364020ed55be49754c987b", + "packages/app/src/components/session/session-context-tab.tsx": "7af0d57b5ec319b662238f5647536628f14e636bc9739a6c69f27ae74424b127", "packages/app/src/components/session/session-fleet-control-projection.ts": "c1a91ce975dd0399251e491091af81abf2d24c0bbba074a334b58c56868bcad1", "packages/app/src/components/session/session-fleet-peers.test.ts": "0b9e128d62f09907691d676b560edd610b6d92fa919d38705addd888d6fce54a", "packages/app/src/components/session/session-fleet-peers.ts": "474fd37998061f1c2df1be81c88e8362654f5aee6bcf79fd4b6669caf2ce50bd", "packages/app/src/components/session/session-header.test.tsx": "733f48a613d3d1fb57ae87a38a0f857d1f37261ee4b1db2bcf0dd8457ec6c196", "packages/app/src/components/session/session-header.tsx": "8d5e943e3b2b338e519530842e5c06cdb30ca5d393d9652cc362e0105f0f7e64", "packages/app/src/components/session/session-new-view.tsx": "98298ae1c8745b8c3d5ec738e4c2354eb84bd7b3d80639fbb32f96374e7be5dd", - "packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe", + "packages/app/src/components/session/session-preview-tab.tsx": "71ee0835b8b212411ba9d117436489202302b0b166f3790709e1551192d43a81", "packages/app/src/components/session/session-preview-tabs.test.ts": "773fc0116c5f61302eaa4a09fd5f91e6b26dee978c89e29c21714243ae3a5653", "packages/app/src/components/session/session-preview-tabs.ts": "672d147a6f1f46a7ac2eff7aff9da6cc069c476ff918e5ff4aed3a997310e6fe", - "packages/app/src/components/session/session-preview-tree.test.ts": "6d1677caa56ec89fa5fcc8fc2f26fdcc1bc0a23217ce9c6d92888b272523c00a", - "packages/app/src/components/session/session-preview-tree.ts": "13930fcd8a8ae9df4c37ba9ea306e162fd08f0e429249ddea21d937675d3ad7e", + "packages/app/src/components/session/session-preview-tree.test.ts": "36aa705c79e39f42f4281e3ef3b6de41c445d18c044f5ce262a41a50bc5cdc59", + "packages/app/src/components/session/session-preview-tree.ts": "b4b2a227c8c153a41b4d7e3bc4eff0501c1fee40db0a5224d3f5048190c915cd", "packages/app/src/components/session/session-sortable-tab.tsx": "a015277347fe679ac1afa1372202de49fc98a0930ac10682c8897f70a0300909", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", "packages/app/src/components/session/use-context-warning.ts": "af7a6d0159a5541aa02ad4d08fd694af1cc4853fc1c0a70763bc264a634d1c53", @@ -800,8 +859,8 @@ "packages/app/src/pages/home/legacy-home.tsx": "43c48bfadf63c56660c8ae4dce15ef2526e87aca36b375f863510dd76e64000e", "packages/app/src/pages/layout/helpers.test.ts": "3a77cfe018ce2867cccded2c726f556e53a9895a77baa944a4878714b9b2b59e", "packages/app/src/pages/layout/helpers.ts": "b9b9bd551833390d9dcb7e2bc56de77abdef770d7d786f732930ed2bb6b8f4d6", - "packages/app/src/pages/layout/notification-toasts.test.ts": "6a95bd3dccb03be894b5d2adb4bc1f7531a9ec704e4d0eae1eba4ec89bbc9846", - "packages/app/src/pages/layout/notification-toasts.ts": "11256e25f2bcc20d3520e81aa50b53db2a7f8dea8047b17e99deb1f9f061e43d", + "packages/app/src/pages/layout/notification-toasts.test.ts": "b0fd654ff36d53c69cde039f9ee813763756376f08eedaca2fabd5ce63749af7", + "packages/app/src/pages/layout/notification-toasts.ts": "110dcef6f667dee7d51a4cb71fc2ef5c52d70dc2d5ccf7e36bf8d0da68dd7c93", "packages/app/src/pages/layout/project-avatar-state.ts": "45ca696114049e23f5c5f95745b3f7303a35d847bd186c426618cf88f355865d", "packages/app/src/pages/layout/session-list-chip.test.ts": "ebda802582ebd274e8c7c4c20ca962e8f699fce482202d3af885bd704104bf46", "packages/app/src/pages/layout/session-list-chip.tsx": "62373670362fd78e944753b2839820411b5c67dea369dec6a39c5d03df827b16", @@ -820,8 +879,10 @@ "packages/app/src/pages/new-session/new-session-machine-picker.test.ts": "2f2adf9d738aee98f2989c5453c97eeba0e48522b7863c99d9c0be9a6c11ff3b", "packages/app/src/pages/new-session/new-session-machine-selection.ts": "618df365b74547d997920fa3d14e1f73f3b5d618e54f2b1297b8374535900f7a", "packages/app/src/pages/new-session/new-session-view.test.ts": "eb337485bf03facbc59d9eadac37cfeb04f75bf97814df80d3be4d0ec9455047", - "packages/app/src/pages/new-session/new-session-view.tsx": "8fa065e3d792f99de6fdb6f0de8c6205fab519159342655fe59fc09ea67691b1", + "packages/app/src/pages/new-session/new-session-view.tsx": "9390bb0c6d24d6ad1cbc753b6a57d85488a7355264e37641991a931769a3f482", "packages/app/src/pages/session/amicode-session-relay.integration.test.ts": "4991e775d3a6c63a514072ccfa5aad190c586383b77ef41689c853fb271d3bf4", + "packages/app/src/pages/session/composer-refocus.test.ts": "fb2fc95d2b4284e039f59552fe82232f1c190a613b40ef39529b310c92aeea30", + "packages/app/src/pages/session/composer-refocus.ts": "4e837f00cd0e606d32ca648a0f310dfd605a897f17efc82696f257af8d1802f5", "packages/app/src/pages/session/fleet-manager-tab.test.tsx": "f3bb956e343ccd82acd8bf38c9ad2413d48b24ec9f484c5dfd3939d9e07139b7", "packages/app/src/pages/session/fleet-manager-tab.tsx": "1e7db225a1f2fa3234e0eb47f314de49185db1f3ebddb5585fd0247184adebaa", "packages/app/src/pages/session/fleet-manager.test.ts": "b10aa624fba721c66e295a08f5bb862363701e09fe870295028c8d4b39d3b850", @@ -833,7 +894,7 @@ "packages/app/src/pages/session/session-panel-width.test.ts": "9482afba7fbce254cbbd21ef885e9e9620b981616681635376f466478f611155", "packages/app/src/pages/session/session-panel-width.ts": "02ee3a02ed78db2eac47c2528055cf78aef95f389641e929ddc56c17471dcbe2", "packages/app/src/pages/session/session-side-panel-structure.test.ts": "b2d1d007d9200d0ac4a0889e45c2dbe57a324a10cb26ae9ab8f420d3c890402b", - "packages/app/src/pages/session/session-side-panel.tsx": "a27806be45c93c2cd8ae24639829cca936c2e444fdab1ff224270847effdc47c", + "packages/app/src/pages/session/session-side-panel.tsx": "43cbf60797b5b382e1cc6e69015f242d684ab92eb68f45a5dcef9cf56422d59a", "packages/app/src/pages/session/terminal-panel-v2.tsx": "68dad9307f1d2abf3ff9248e451bd08005acf01ddc46b30a6d01b58f4a90dce0", "packages/app/src/pages/session/use-amicode-commands.test.ts": "65671d054c404edd2b799b0f6591a9c055e6ecb454a674fa0bd9a7fa6f68e111", "packages/app/src/pages/session/use-amicode-commands.tsx": "88bfdad9ae6dfd920b8e6a14ffebd2cf9e52b8f67bb9284cb358ad940643ca67", @@ -900,8 +961,8 @@ "packages/session-ui/src/v2/components/editable-diff-view-core.ts": "f01a341c4c1cdb4a9522f9ccd3c856bd3b9b7310c01d87327b3ce777052c8c59", "packages/session-ui/src/v2/components/editable-diff-view.test.ts": "ab206fa17779e6e9015d343ae37e3067dfc1821a93b6ba566b7a0dce0fc19acb", "packages/session-ui/src/v2/components/editable-diff-view.tsx": "f682e398ece864b5b854ca36698581c7e1cc5964eed96e7b45cabfcb64748b24", - "packages/session-ui/src/v2/components/editor-core.test.ts": "bd6552c0e7080afd554cb57ad690cc1504555712274c8b02702c64bb09460234", - "packages/session-ui/src/v2/components/editor-core.ts": "8306fc9b87fffe853c96212664ba5003c4e020c8779fc7d5410c26b543736a7e", + "packages/session-ui/src/v2/components/editor-core.test.ts": "b2fa5f3a2395862fe59692e346568299acf76ad639d0d1336b09fec9cdaf4694", + "packages/session-ui/src/v2/components/editor-core.ts": "1457c2e2e223f6bbe0a1a5c8c9fd45974ebd86cefc90442ce6df02ae79d5946c", "packages/session-ui/src/v2/components/file-name-picker.test.tsx": "1ce9741e315b549165d390c74b5885a43580aa70e24de987f9ba8388cd521b41", "packages/session-ui/src/v2/components/markdown-utils.test.ts": "10e1034293bf385cf50d860bf1667d8370abc61dcee0c5a93295f21f5f55876a", "packages/session-ui/src/v2/components/markdown-utils.ts": "3bc47cfed5b99ab23f857d5324be3189f3a7e800d991103d72ef1c2f970b910b", @@ -909,7 +970,7 @@ "packages/session-ui/src/v2/components/preview-editor-helpers.ts": "7f61e97e54286af1bbf5e88538e9c73995eb30f19868c6f43694422205aeda4b", "packages/session-ui/src/v2/components/preview-editor.test.ts": "b8da9320a594aaa680ed3848612fcea66f41c0a046a673f0cf6155c2a0b697dd", "packages/session-ui/src/v2/components/preview-editor.tsx": "96cfbfbbe06cc797f71ac667a29ece3c44a192c9cd94d91e90f79359cdab932b", - "packages/session-ui/src/v2/components/preview-file-routing.test.ts": "fca02a21261b291fd94001baa2294e7b2fa45b18b0c0403848f045763b41bf15", + "packages/session-ui/src/v2/components/preview-file-routing.test.ts": "6422e3b36de18a19b101f7321ae2b93e5bcd19a5d1b6e588b64f34af91098d6d", "packages/session-ui/src/v2/components/preview-nav-state.test.ts": "cec09a8b3c08ab16581d3d5b6ff704584b3c87d81c25e8af20f6f00272f3593f", "packages/session-ui/src/v2/components/preview-nav-state.ts": "ccaeb94d1ab39271f5dcd49c935c8fc7b54d4ffbd66e1352e4d797c9205553fc", "packages/session-ui/src/v2/components/preview-view-state.ts": "4ec961c7720417166dcccd45f0d4702afec664dfe7f4d0dd8668bb9b1d8af5e6", @@ -951,22 +1012,22 @@ "packages/app/src/pages/session/composer/session-bug-dock.stories.tsx": "a5730bb3699751d63629134924ce8dea021b264722c40f5f6bb98236dc15eda7", "packages/app/src/pages/session/composer/session-bug-dock.tsx": "e7fed3c5ab80527d69a106440833f795bbbafc5deeba3883902ede9fba612605", "packages/app/src/pages/session/composer/session-composer-controls.ts": "7ba9d56c81b1547acbdd47c4f9371ddc37c9a92059f5785f7286fc68e0cb4483", - "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "d0403567856eb07ee93957e6d8a23c0f5c78620d94fa09407c01fc6ad3d9eadc", - "packages/app/src/pages/session/composer/session-composer-region.tsx": "415cd5c3a77b625a3142b828b6458687e1a35b382d3a208d90ff1555cc7098b1", + "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "39b05c06b9e9ed42678d6b35dd2e2acd323e0ede8587a5592923c74f38307aca", + "packages/app/src/pages/session/composer/session-composer-region.tsx": "fcede95d91c72254d46481140adb3ff33d5ec69a54760f5a201fe87d0a2b1e6e", "packages/app/src/pages/session/composer/session-composer-state.ts": "fcffa11d801309cf58f42a174143bf6eb3ea2b14f860ad91a8ffa4e097df5c16", "packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts": "a1fe31fe4dfdf2914bc184030a0427bcbdfae0f3e7b146e1d6ee29ce31d85b64", "packages/app/src/pages/session/composer/session-question-dock.helpers.ts": "b16282d0cf5d39152bd76f4ef8bac32f964f68143a3067765ae0827a5f90deed", "packages/app/src/pages/session/composer/session-question-dock.tsx": "44c0814284ef4a5273c8034b8d1f5cd45306406d04e2f4f6646000facae47d7e", "packages/app/src/pages/session/composer/session-revert-dock.stories.tsx": "0310ce448c1f8feaf4732e66429654fe3602d470f3121835cf6435084c455c36", "packages/app/src/pages/session/composer/session-revert-dock.tsx": "3f2d030126a4c673731bac65406fd48797464ddc515444b891e39b0eb538e002", - "packages/app/src/pages/session/composer/session-tour.test.ts": "147473505d54466417b6b34fa672d280b71e1b9577e8bd41fbf9870035d1083e", - "packages/app/src/pages/session/composer/session-tour.tsx": "da5d807c7efe7aed1140d65d7eddd7982a8c17348d928f5d0a887d1b2a8d3a50", + "packages/app/src/pages/session/composer/session-tour.test.ts": "82bb857109fcc727670bd9f748aaa8e2408aea0446d2f48b4990098e30fcea5e", + "packages/app/src/pages/session/composer/session-tour.tsx": "ddc3b58d52e9bbbe73eea80a7d02ead93eef51613feee40f1b525f489b8b5c91", "packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx": "70a8267c725af320d78d266b85fd0ff005d067c4d09a21bef824126602e831e7", "packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "83753b3ec7227570523c2e0b9e82401fae7a20f3a23980bb40deaeb13304327b", "packages/app/src/pages/session/timeline/message-timeline-archive.test.ts": "5699d233b5b1a8ba165354113ff40cc9d55b37a80787d163c15165655d591555", - "packages/app/src/pages/session/timeline/message-timeline.tsx": "cfb17097fc20b91c7d18f3e0138068353e00b485a13f77851e4707833e9f2271", - "packages/app/src/pages/session/timeline/model-pure.ts": "50be4ba12d747dd8df9932044ceee29c5f3b6fa6d6ac36791207f91a91067cd4", - "packages/app/src/pages/session/timeline/model.test.ts": "459e0694409ffcf2530252a65491e1006f982b8f5c78a15573ed41646b2c85cf", + "packages/app/src/pages/session/timeline/message-timeline.tsx": "1b628ed55f3f55aa9fa036e6ae3c01b90b25c6e65893547b517e76abe8b84ac6", + "packages/app/src/pages/session/timeline/model-pure.ts": "2f12228b5eec30178634d481c6d92b44bf727d47da02bdb5e4f2f9f642690250", + "packages/app/src/pages/session/timeline/model.test.ts": "ea9f4617e32ad7e33f9ce431104e29586cddbb20377cb245489790a738019804", "packages/app/src/pages/session/timeline/model.ts": "a67995b33bb7e7bc44994ff3e4511bb6ff0200448d7c3aa5251d5f18a133586e", "packages/app/src/pages/session/timeline/projection.test.ts": "68655f29c382640fc281780166aa528ea26d28d953f3376e1e062be7a0e13749", "packages/app/src/pages/session/timeline/projection.ts": "e76549e34baedee02c8d2fd9357245f80aa4e79dcf2916869f48f0ea1efed980", @@ -999,20 +1060,24 @@ "packages/sdk/js/src/v2/gen/sdk.gen.ts": "6610638b4fb71bac498da98d232ef9adcd4b011b63e5e81a63fc6dc20d311b17", "packages/sdk/js/src/v2/gen/types.gen.ts": "20dfc17423861bd51c197fe6a2fcb6efbd7ad11853b28e0bb79844115256e729", "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "ff66078f2edc9a67a4df0d7dfd76a49be5eab27ac332b303f3412721ef7268ea", - "packages/session-ui/src/v2/components/prompt-input/index.tsx": "098bf84bf22573fcab2826a827e6789d41ad660545a0364ffb4a77cda5e01b30", + "packages/session-ui/src/v2/components/prompt-input/index.tsx": "f3e79853a30c71b7c7ec99ca7c092695ce59b03233c8cf0f6e094fcf00498c60", + "packages/session-ui/src/v2/components/prompt-input/resize-handle.test.ts": "1bd88c8e864d5d240f8b00f9e3bf4a265c79282c0073b7711d797ff7d7e81d6e", + "packages/session-ui/src/v2/components/prompt-input/resize-handle.tsx": "61362de659c8000539bdb20bd710b86967e85df6cf5b4ecfb2fa3bae46cb4e0a", + "packages/session-ui/src/v2/components/prompt-input/resize.test.ts": "c049ad1cd60e5ac4ec779dd37ab4b2c8a6244b5b6cbbc59240d1c43a449cfabd", + "packages/session-ui/src/v2/components/prompt-input/resize.ts": "bf1323b437bd0cc342084ac9d21035c8ea6d12fcdf644b5587fd88453638f83c", "packages/opencode/src/server/routes/instance/httpapi/server.ts": "8f142f16accad5a5a7ca84507f5c95863416af9f85b11131e5cd97cb6b128d90", "packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts": "9da41a900811e8526f3d6796341b787f7e3c64bdc9cc3a547977f05780c1f79f", "packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts": "944a0aee215a917e867fd9482f3c2bd40f49082bd3174c8a7d967ca40e34ec15", "packages/opencode/src/server/routes/instance/httpapi/groups/file.ts": "a96fdc55ddb95ca7c481c86f67e37ed88d4cf2a190a482765ab55ce4409f3a59", "packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts": "048efa7b74d43c4ad4a75d0f8421c808760c8c0dcd593f0051a87044e40487d6", - "packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts": "34c6f1ab1a085d1b047c220bbff32a36032dc5e924784e9272cda166cf0ec84b", + "packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts": "2d090445392a2c52efb26335297f4d44c70aa511b0524e4cd3a69880ffdc6078", "packages/opencode/src/server/routes/instance/httpapi/groups/session.ts": "1421f8c07948b121c7e2005bd9b0fcd6b2c456565ddd1da32d8e6c35d03fac9c", "packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts": "88c7bae32fa01ab213e95cb4e4891fd3ccb961782e80a416e321c5daf90fb487", "packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts": "69ca3dd48d95157fc822e1f76eb42ac873df50f22616b85194619ca658613867", "packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "10bcb79b117beebee04184fb48c1ad789e782e2f7ae4b0c68603edc00f03aee9", "packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts": "040554475b6ba22ad666dfeaca2576658854c46cd18efe3b2d68959f9eaaed80", "packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts": "7da08d58765cdf2b6ce4648fc70de371402949f5bb7b75696f674d43f2d5365b", - "packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "8baf16cad0992c5351b61b0c0fb27903bb25b17e1b25fc7c5d77f807c24413a6", + "packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "e899320a028e639a845d6c6aef547d448341671a195f5465ee4e8fa847957c52", "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f", "packages/opencode/src/server/routes/instance/httpapi/middleware/file-write-workspace.ts": "665c33633173ee89c6da1d4a0eaacaf4eebe3ccdd04eb36b063515cb4c093e4a", "packages/opencode/src/server/routes/instance/httpapi/middleware/gateway-secret.ts": "ec6133ee5d331920ded81042fbeb05221bec0c4b26f5897b3f28bc267d26aefa", @@ -1031,11 +1096,11 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-26T18:34:42.306Z", + "extracted_at": "2026-10-30T20:21:59.423Z", "per_package": { "packages/app": { - "A": 257, - "M": 164, + "A": 307, + "M": 166, "D": 0 }, "packages/core": { @@ -1049,7 +1114,7 @@ "D": 0 }, "packages/opencode": { - "A": 101, + "A": 103, "M": 100, "D": 0 }, @@ -1069,18 +1134,18 @@ "D": 0 }, "packages/session-ui": { - "A": 45, + "A": 55, "M": 32, "D": 0 }, "packages/ui": { - "A": 150, + "A": 151, "M": 66, "D": 0 } }, "counts": { - "overlay_total": 1009, + "overlay_total": 1074, "deletions": 7, "server_coupled": 0 }, @@ -1186,6 +1251,9 @@ "packages/app/src/components/amicode-default-model.tsx": "A", "packages/app/src/components/amicode-defaults-capsule.tsx": "A", "packages/app/src/components/amicode-file-ref-bridge.tsx": "A", + "packages/app/src/components/amicode-local-image-loader.test.ts": "A", + "packages/app/src/components/amicode-local-image-loader.ts": "A", + "packages/app/src/components/concise-mode-toggle.tsx": "A", "packages/app/src/components/connection-banner.tsx": "A", "packages/app/src/components/dialog-command-palette-v2.css": "M", "packages/app/src/components/dialog-command-palette-v2.tsx": "M", @@ -1198,6 +1266,13 @@ "packages/app/src/components/dialog-release-notes.tsx": "M", "packages/app/src/components/dialog-select-directory-v2.css": "M", "packages/app/src/components/dialog-select-file.tsx": "M", + "packages/app/src/components/dialog-select-model-reachability-source.ts": "A", + "packages/app/src/components/dialog-select-model-reachability.test.ts": "A", + "packages/app/src/components/dialog-select-model-reachability.ts": "A", + "packages/app/src/components/dialog-select-model-shortlist-source.ts": "A", + "packages/app/src/components/dialog-select-model-shortlist.test.ts": "A", + "packages/app/src/components/dialog-select-model-shortlist.ts": "A", + "packages/app/src/components/dialog-select-model.tsx": "M", "packages/app/src/components/engine-state-signal.ts": "A", "packages/app/src/components/engine-toggle-utils.test.ts": "A", "packages/app/src/components/engine-toggle-utils.ts": "A", @@ -1206,10 +1281,22 @@ "packages/app/src/components/harness-switch.test.ts": "A", "packages/app/src/components/harness-switch.ts": "A", "packages/app/src/components/help-button.tsx": "M", + "packages/app/src/components/model-arrivals-source.ts": "A", + "packages/app/src/components/model-arrivals.test.ts": "A", + "packages/app/src/components/model-arrivals.ts": "A", + "packages/app/src/components/model-dead-pick-warning.tsx": "A", + "packages/app/src/components/model-dead-pick.test.ts": "A", + "packages/app/src/components/model-dead-pick.ts": "A", + "packages/app/src/components/model-picker-row.test.tsx": "A", + "packages/app/src/components/model-picker-row.tsx": "A", + "packages/app/src/components/model-row-meta.test.ts": "A", + "packages/app/src/components/model-row-meta.ts": "A", + "packages/app/src/components/model-tooltip.tsx": "M", "packages/app/src/components/new-session-machine-picker.ts": "A", "packages/app/src/components/pane-bridge.tsx": "A", "packages/app/src/components/profile-popover.tsx": "A", "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "A", + "packages/app/src/components/prompt-input-resize-structure.test.ts": "A", "packages/app/src/components/prompt-input-v2.tsx": "M", "packages/app/src/components/prompt-input.tsx": "M", "packages/app/src/components/prompt-project-selector.tsx": "M", @@ -1254,6 +1341,8 @@ "packages/app/src/components/windows-app-menu.tsx": "M", "packages/app/src/components/workbench-panel.tsx": "A", "packages/app/src/context/command.tsx": "M", + "packages/app/src/context/concise-mode.test.ts": "A", + "packages/app/src/context/concise-mode.ts": "A", "packages/app/src/context/directory-sync.test.ts": "A", "packages/app/src/context/directory-sync.ts": "M", "packages/app/src/context/fleet-reseed-scheduler.test.ts": "A", @@ -1270,7 +1359,11 @@ "packages/app/src/context/layout.tsx": "M", "packages/app/src/context/local-agent.test.ts": "M", "packages/app/src/context/local-agent.ts": "M", + "packages/app/src/context/local-reseed.test.ts": "A", + "packages/app/src/context/local-reseed.ts": "A", "packages/app/src/context/local.tsx": "M", + "packages/app/src/context/models-favorites.test.ts": "A", + "packages/app/src/context/models-favorites.ts": "A", "packages/app/src/context/models.tsx": "M", "packages/app/src/context/platform.tsx": "M", "packages/app/src/context/question-inbox.test.ts": "A", @@ -1435,6 +1528,7 @@ "packages/opencode/src/project/instance-store.ts": "M", "packages/opencode/src/provider/auth.ts": "M", "packages/opencode/src/provider/error.ts": "M", + "packages/opencode/src/provider/probe.ts": "A", "packages/opencode/src/provider/provider.ts": "M", "packages/opencode/src/provider/transform.ts": "M", "packages/opencode/src/question/index.ts": "M", @@ -1482,6 +1576,7 @@ "packages/opencode/test/effect/runner.test.ts": "M", "packages/opencode/test/fixture/fixture.ts": "M", "packages/opencode/test/lib/cli-process.ts": "M", + "packages/opencode/test/mcp/remove-static-config.test.ts": "A", "packages/opencode/test/permission/permission-store.test.ts": "A", "packages/opencode/test/project/worktree-rename.test.ts": "A", "packages/opencode/test/provider/combine-abort-signals.test.ts": "A", @@ -1570,9 +1665,12 @@ "packages/session-ui/src/components/build-trace.test.ts": "A", "packages/session-ui/src/components/build-trace.ts": "A", "packages/session-ui/src/components/group-parts.test.ts": "A", + "packages/session-ui/src/components/image-lightbox.tsx": "A", "packages/session-ui/src/components/line-comment-styles.ts": "M", "packages/session-ui/src/components/markdown-file-refs.test.ts": "A", "packages/session-ui/src/components/markdown-file-refs.ts": "A", + "packages/session-ui/src/components/markdown-local-images.test.ts": "A", + "packages/session-ui/src/components/markdown-local-images.ts": "A", "packages/session-ui/src/components/markdown-stream.test.ts": "M", "packages/session-ui/src/components/markdown-stream.ts": "M", "packages/session-ui/src/components/markdown.css": "M", @@ -1584,6 +1682,8 @@ "packages/session-ui/src/components/message-part-task.test.ts": "A", "packages/session-ui/src/components/message-part-task.ts": "A", "packages/session-ui/src/components/message-part-text.ts": "M", + "packages/session-ui/src/components/message-part-tool-status.test.ts": "A", + "packages/session-ui/src/components/message-part-tool-status.ts": "A", "packages/session-ui/src/components/message-part-user.ts": "A", "packages/session-ui/src/components/message-part.css": "M", "packages/session-ui/src/components/message-part.test.ts": "M", @@ -1602,6 +1702,7 @@ "packages/session-ui/src/components/tool-status-title.tsx": "M", "packages/session-ui/src/context/data.tsx": "M", "packages/session-ui/src/pierre/worker.ts": "M", + "packages/session-ui/src/util/clipboard.test.ts": "A", "packages/session-ui/src/util/clipboard.ts": "A", "packages/ui/src/amicode/activity-dot.tsx": "A", "packages/ui/src/amicode/activity.test.ts": "A", @@ -1625,6 +1726,7 @@ "packages/ui/src/amicode/brain-data.ts": "A", "packages/ui/src/amicode/brain-engine.test.ts": "A", "packages/ui/src/amicode/brain-engine.ts": "A", + "packages/ui/src/amicode/brain-ref.test.ts": "A", "packages/ui/src/amicode/brain-ref.ts": "A", "packages/ui/src/amicode/bridge.ts": "A", "packages/ui/src/amicode/card.stories.tsx": "A", @@ -1801,6 +1903,11 @@ "packages/app/src/amicode/inspector/inspector-bridge.ts": "A", "packages/app/src/amicode/inspector/inspector-context.tsx": "A", "packages/app/src/amicode/inspector/run-inspector.tsx": "A", + "packages/app/src/amicode/whats-new/whats-new-model.test.ts": "A", + "packages/app/src/amicode/whats-new/whats-new-model.ts": "A", + "packages/app/src/amicode/whats-new/whats-new-view.test.tsx": "A", + "packages/app/src/amicode/whats-new/whats-new-view.tsx": "A", + "packages/app/src/amicode/whats-new/whats-new.tsx": "A", "packages/app/src/components/prompt-input/attachments.ts": "M", "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "A", "packages/app/src/components/prompt-input/clipboard-bridge.ts": "A", @@ -1813,6 +1920,7 @@ "packages/app/src/components/prompt-input/placeholder.test.ts": "M", "packages/app/src/components/prompt-input/placeholder.ts": "M", "packages/app/src/components/prompt-input/slash-popover.tsx": "M", + "packages/app/src/components/prompt-input/submit-concise.test.ts": "A", "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "A", "packages/app/src/components/prompt-input/submit.ts": "M", "packages/app/src/components/server/server-row.tsx": "M", @@ -1820,17 +1928,33 @@ "packages/app/src/components/session/index.ts": "M", "packages/app/src/components/session/panel-menu.tsx": "A", "packages/app/src/components/session/pdf-canvas-view.tsx": "A", + "packages/app/src/components/session/pdf-find-controller.ts": "A", + "packages/app/src/components/session/pdf-find-core.test.ts": "A", + "packages/app/src/components/session/pdf-find-core.ts": "A", + "packages/app/src/components/session/pdf-find-guard.ts": "A", + "packages/app/src/components/session/pdf-find-highlight.ts": "A", + "packages/app/src/components/session/pdf-find-pill.tsx": "A", "packages/app/src/components/session/pdf-restore.test.ts": "A", "packages/app/src/components/session/pdf-restore.ts": "A", + "packages/app/src/components/session/preview-draft-store.test.ts": "A", + "packages/app/src/components/session/preview-draft-store.ts": "A", + "packages/app/src/components/session/preview-draft-wiring.test.ts": "A", "packages/app/src/components/session/preview-file-helpers.test.ts": "A", "packages/app/src/components/session/preview-file-helpers.ts": "A", + "packages/app/src/components/session/preview-file-target.test.ts": "A", + "packages/app/src/components/session/preview-file-target.ts": "A", "packages/app/src/components/session/preview-file-view.tsx": "A", + "packages/app/src/components/session/preview-find-in-sidebar.test.ts": "A", "packages/app/src/components/session/preview-frontmatter-card.tsx": "A", "packages/app/src/components/session/preview-frontmatter.test.ts": "A", "packages/app/src/components/session/preview-frontmatter.ts": "A", "packages/app/src/components/session/preview-human-write-gate.ts": "A", + "packages/app/src/components/session/preview-latex-compile.test.ts": "A", + "packages/app/src/components/session/preview-latex-compile.ts": "A", + "packages/app/src/components/session/preview-restore.test.ts": "A", "packages/app/src/components/session/session-archived-fleet.test.ts": "A", "packages/app/src/components/session/session-chats-dropdown.test.ts": "A", + "packages/app/src/components/session/session-context-tab.test.ts": "A", "packages/app/src/components/session/session-context-tab.tsx": "M", "packages/app/src/components/session/session-fleet-control-projection.ts": "A", "packages/app/src/components/session/session-fleet-peers.test.ts": "A", @@ -1899,6 +2023,8 @@ "packages/app/src/pages/new-session/new-session-view.test.ts": "A", "packages/app/src/pages/new-session/new-session-view.tsx": "M", "packages/app/src/pages/session/amicode-session-relay.integration.test.ts": "A", + "packages/app/src/pages/session/composer-refocus.test.ts": "A", + "packages/app/src/pages/session/composer-refocus.ts": "A", "packages/app/src/pages/session/fleet-manager-tab.test.tsx": "A", "packages/app/src/pages/session/fleet-manager-tab.tsx": "A", "packages/app/src/pages/session/fleet-manager.test.ts": "A", @@ -2077,6 +2203,10 @@ "packages/sdk/js/src/v2/gen/types.gen.ts": "M", "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "M", "packages/session-ui/src/v2/components/prompt-input/index.tsx": "M", + "packages/session-ui/src/v2/components/prompt-input/resize-handle.test.ts": "A", + "packages/session-ui/src/v2/components/prompt-input/resize-handle.tsx": "A", + "packages/session-ui/src/v2/components/prompt-input/resize.test.ts": "A", + "packages/session-ui/src/v2/components/prompt-input/resize.ts": "A", "packages/opencode/src/server/routes/instance/httpapi/server.ts": "M", "packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts": "M", "packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts": "M", diff --git a/packages/app-bundle/overlay/packages/app/src/amicode/whats-new/whats-new-model.test.ts b/packages/app-bundle/overlay/packages/app/src/amicode/whats-new/whats-new-model.test.ts new file mode 100644 index 000000000..c8430a6ef --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/whats-new/whats-new-model.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from "bun:test" +import { + buildCardViews, + initialExpanded, + isLongBody, + keyAction, + lastSeenStorageKey, + notesLink, + summaryLine, + toggleIn, + whatsNewKeyHandler, + type WhatsNewCard, + type WhatsNewPayload, + parseReleasesBody, + readLastSeenLocal, + releasesRoute, + requestWhatsNew, + onWhatsNewRequest, + resolveLastSeen, + writeLastSeenLocal, +} from "./whats-new-model" + +function memStorage() { + const m = new Map() + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => void m.set(k, v), + m, + } +} + +describe("last-seen storage (web clients) is keyed by host", () => { + test("two hosts never share a last-seen version", () => { + const s = memStorage() + writeLastSeenLocal(s, "https://hub-a.example:4096/", "0.4.0") + expect(readLastSeenLocal(s, "https://hub-a.example:4096")).toBe("0.4.0") + expect(readLastSeenLocal(s, "https://hub-b.example")).toBeNull() + expect(lastSeenStorageKey("https://hub-a.example:4096/x")).toBe("amicode.whatsNew.lastSeen:hub-a.example:4096") + }) + + test("a junk stored value reads as never-seen", () => { + const s = memStorage() + s.setItem(lastSeenStorageKey("http://h"), "