diff --git a/.notturno/gates b/.notturno/gates index fdfedf49c..ae15ead5b 100755 --- a/.notturno/gates +++ b/.notturno/gates @@ -18,8 +18,20 @@ # 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` precedes the -# suites, as in CI (several suites read built bundles). +# 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] +# Divergences the gate cannot erase are printed as `warn:` lines: +# node major != CI's 20, running as root (CI's runner is not — +# chmod-000 tests cannot fail for root). # NOT here (need the vendored engine binary / bun materialization / Julia / # PyPI): build-binary, engine-tests, vsix-gate, boot-smoke, schema-roundtrip. # @@ -30,7 +42,9 @@ # 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,vitest (default all) +# 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) set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -45,16 +59,16 @@ PLAN=0 for a in "$@"; do case "$a" in --plan) PLAN=1 ;; - -h|--help) sed -n '2,36p' "$0"; exit 0 ;; + -h|--help) sed -n '2,52p' "$0"; exit 0 ;; *) echo "[notturno-gates] RESULT: RED — unknown argument: $a"; exit 2 ;; esac done -STEPS="${NOTTURNO_GATES_STEPS:-drift,typecheck,vitest}" +STEPS="${NOTTURNO_GATES_STEPS:-drift,typecheck,build,vitest}" for s in ${STEPS//,/ }; do case "$s" in - drift|typecheck|vitest) ;; - *) echo "[notturno-gates] RESULT: RED — unknown step '$s' in NOTTURNO_GATES_STEPS (known: drift,typecheck,vitest)"; exit 2 ;; + 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; } @@ -146,9 +160,11 @@ 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=$? @@ -156,28 +172,113 @@ step() { # step PASSED="$PASSED $name" say "── $name: ok ($((SECONDS - t))s)" else - FAILED="$FAILED $name" + FAILED="$FAILED $name$STEP_DETAIL" [ -n "$hint" ] && HINTS="$HINTS $hint;" - say "── $name: FAIL exit $rc ($((SECONDS - t))s)" + 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; } +trap 'chmod -R u+rwx "$SCRATCH" 2>/dev/null; rm -rf "$SCRATCH"' EXIT +CI_TMP="$SCRATCH/tmp" +mkdir -p "$CI_TMP" "$SCRATCH/runner-temp" + +# run a command the way CI's `fast` job runs a test step +ci_env() { + if [ "${NOTTURNO_GATES_HERMETIC:-1}" = "0" ]; then "$@"; return; fi + local home + home="$(mktemp -d "$SCRATCH/home.XXXXXX")" || return 2 # fresh + empty per suite + local keep=() v + for v in PATH LANG LC_ALL USER LOGNAME SHELL; do + [ -n "${!v:-}" ] && keep+=("$v=${!v}") + done + 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 vexit excl=() g - out="${TMPDIR:-/tmp}/notturno-amico-run-$$.json" - for g in $ENV_DEPENDENT_EXCLUDES; do excl+=(--exclude "$g"); done - (cd packages/amico-run && pnpm exec vitest run --passWithNoTests --exclude '**/slow/**' "${excl[@]}" \ - --reporter=default --reporter=json --outputFile.json="$out") + 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") + (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" excl=() g - for g in $ENV_DEPENDENT_EXCLUDES; do excl+=(--exclude "$g"); done - (cd "packages/$p" && pnpm exec vitest run --passWithNoTests --exclude '**/slow/**' "${excl[@]}") + 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" + [ "$(id -u 2>/dev/null)" = "0" ] && say "warn: running as root — CI's runner is unprivileged; permission tests (chmod 000/0500 dirs) cannot fail for root" +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 @@ -186,7 +287,7 @@ if want typecheck; then step typecheck "" pnpm -r run typecheck fi if want vitest && [ -n "$SELECTED" ]; then - step build "" pnpm -r run build + want build && step build "" pnpm -r run build for p in $SELECTED; do if [ "$p" = "amico-run" ]; then step "vitest:$p" "" run_amico_run_suite 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/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/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/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/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/notturno_gates.test.ts b/packages/amico-run/test/notturno_gates.test.ts index 8afd27a77..044d05796 100644 --- a/packages/amico-run/test/notturno_gates.test.ts +++ b/packages/amico-run/test/notturno_gates.test.ts @@ -15,7 +15,7 @@ // 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, rmSync, statSync, writeFileSync, appendFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, appendFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -174,6 +174,83 @@ describe(".notturno/gates — the night runner's pre-push gate (#1980)", () => { }); }); +// #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"); + }); +}); + 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"); 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/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 1f961b1fd..c4f0c7ffc 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", @@ -148,7 +151,7 @@ "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-v2.tsx": "f0b637e46737e3c7345957a0167c878c8e9870a15bb850057665a4444ea91fe8", "packages/app/src/components/prompt-input.tsx": "c127f81e2c0de4bdbc21a349473df39c623d8d7e2df8df0c03ea60bbef101a25", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", "packages/app/src/components/question-inbox.tsx": "5ce03a30b7110092ddffccf3bf38e6c1b1a79624f378c819be7019725c96478a", @@ -166,7 +169,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", @@ -192,6 +195,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", @@ -205,7 +210,7 @@ "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.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", @@ -287,7 +292,7 @@ "packages/app/src/pages/new-session.tsx": "c060bbb15569b6da2f67146e6af0879ccb5444345df03dbc2f9b39cb98b9db78", "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": "a115ebf2fda802db0cf4beda9fd68a521ec7500ba04f877d9c47b248f308f353", "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", @@ -316,7 +321,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", @@ -511,13 +516,16 @@ "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/markdown.css": "e5654bf01501ab93bcad54b488b460d6ca283da9875978557f853106738ec830", + "packages/session-ui/src/components/markdown.tsx": "9eba82666012819518e4e9145e75373e3a6d1d0d33738da639c8e5be117c43c5", "packages/session-ui/src/components/message-part-css.test.ts": "bc164695d0195684d646ebf490ba7bb4ed72adcf2bf3d01b00de47f4f03bcd4f", "packages/session-ui/src/components/message-part-groups.ts": "37be069bf46e926fcde83d3744ee706911ff002e6b532e279290d841aeee96da", "packages/session-ui/src/components/message-part-skill.test.ts": "453bdcb25e4df91ad1004635b8766070a60421355adf18ee53f43cd2fa7d0486", @@ -543,7 +551,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", @@ -742,6 +751,9 @@ "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": "49c8b21d35f286c174456991584f66f99b4fdcec6b438b84317d8c6abc43987c", + "packages/app/src/amicode/whats-new/whats-new-model.ts": "30ef4aa0f3b04c6cd2743bb757cadf198d6fd6e463301b8c5645b1cbdd877cbd", + "packages/app/src/amicode/whats-new/whats-new.tsx": "a7ee5d41f4e17b80528efa451b7c15be2bd194813c0b2d4bd22a560dcc7944a7", "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", @@ -754,18 +766,25 @@ "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-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-view.tsx": "caccb771210950896631ac7efd4cb3ca5689e0ba5049d9e2f53154ca86bb07b9", "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", @@ -779,11 +798,11 @@ "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": "8f1244abea55bbed6f6bcb321d4c8f3d10bcbf8f4ead7992e51f04ca525de88f", "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": "8cc3fc50299ffebedd562640e157892461cb1372501cc58d50328b10297ee892", "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", @@ -840,6 +859,8 @@ "packages/app/src/pages/new-session/new-session-view.test.ts": "eb337485bf03facbc59d9eadac37cfeb04f75bf97814df80d3be4d0ec9455047", "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", @@ -851,7 +872,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": "fdf3b2eb6fe7484adb03f6b7fb52dfab2c74a7a41628b8562340d657174abd13", + "packages/app/src/pages/session/session-side-panel.tsx": "2246a789faf29be5cf6c0eccdf62b22f236fd180f98534c37b94ddee9e6b2cd0", "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", @@ -918,8 +939,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": "c758e1fb20da30cae44a3311ddd27591a074f137020b28b79d9d879666ee37d6", + "packages/session-ui/src/v2/components/editor-core.ts": "efc2b5f6fe774f1be862184db381f9229f1e642cf93d1f4ca925ed39ceb211d0", "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", @@ -1049,10 +1070,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-29T08:48:42.118Z", + "extracted_at": "2026-10-29T13:09:53.406Z", "per_package": { "packages/app": { - "A": 272, + "A": 289, "M": 166, "D": 0 }, @@ -1087,7 +1108,7 @@ "D": 0 }, "packages/session-ui": { - "A": 45, + "A": 49, "M": 32, "D": 0 }, @@ -1098,7 +1119,7 @@ } }, "counts": { - "overlay_total": 1027, + "overlay_total": 1048, "deletions": 7, "server_coupled": 0 }, @@ -1204,6 +1225,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", @@ -1287,6 +1311,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", @@ -1606,9 +1632,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", @@ -1638,6 +1667,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", @@ -1837,6 +1867,9 @@ "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.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", @@ -1849,6 +1882,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", @@ -1856,6 +1890,12 @@ "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-file-helpers.test.ts": "A", @@ -1935,6 +1975,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", 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..2ef4b3b6c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/amicode/whats-new/whats-new-model.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test" +import { + lastSeenStorageKey, + 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"), "