From 6ca80f9d21133ab42cc6aba0cd8fe378ac9beb65 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Tue, 15 Sep 2026 16:43:04 -0400 Subject: [PATCH 01/80] docs: add Slack integration setup guide and app manifest - SETUP.md: end-to-end walkthrough for creating the Slack app, installing it, wiring the MCP server, and troubleshooting - slack-manifest.json: ready-to-paste Slack app manifest with user + bot scopes (search:read user-only) Covers the pitfalls we hit: bot_user required for OAuth install, search:read invalid as a bot scope, redirect URI registration for amico slack login, and MCP server config in opencode.json. --- .../extension/skills/amico-slack/SETUP.md | 129 ++++++++++++++++++ .../skills/amico-slack/slack-manifest.json | 46 +++++++ 2 files changed, 175 insertions(+) create mode 100644 packages/extension/skills/amico-slack/SETUP.md create mode 100644 packages/extension/skills/amico-slack/slack-manifest.json diff --git a/packages/extension/skills/amico-slack/SETUP.md b/packages/extension/skills/amico-slack/SETUP.md new file mode 100644 index 000000000..d857b7535 --- /dev/null +++ b/packages/extension/skills/amico-slack/SETUP.md @@ -0,0 +1,129 @@ +# Slack Integration Setup Guide + +How to connect Amicode to a Slack workspace so Amico can read messages, search history, and post on your behalf. + +## Overview + +Amicode talks to Slack through the [`slack-mcp-server`](https://github.com/korotovsky/slack-mcp-server) MCP package. Messages are sent **as you** (user OAuth token), not as a bot. The integration is outbound-only — Amicode calls the Slack Web API when you ask it to; there are no inbound webhooks or event subscriptions. + +The setup has four parts: + +1. Create a Slack app from a manifest +2. Install the app and copy the User OAuth Token +3. Register the localhost redirect URI (for `amico slack login`) +4. Wire the MCP server into the Amicode config + +## Prerequisites + +- Admin (or app-install) permissions on the target Slack workspace +- Node.js / npm installed (for `npx` to run the MCP server) +- Amicode installed and running + +## Step 1: Create the Slack app + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From a manifest** +2. Select your workspace +3. Paste the contents of [`slack-manifest.json`](./slack-manifest.json) (included in this directory) +4. Click **Next**, review the summary, then **Create** + +### About the manifest + +The manifest defines a minimal Slack app with: + +| Section | What it does | +|---------|-------------| +| `bot_user` | Required by Slack's OAuth install flow — sits idle; you use the user token | +| `bot` scopes | Valid bot-token scopes (everything except `search:read`, which is user-only) | +| `user` scopes | The real permissions — read history, list channels/DMs, post messages, search, resolve users | +| `socket_mode_enabled: false` | No inbound event delivery needed | +| `is_hosted: false` | Not a Slack-hosted app | + +**Why both bot and user scopes?** +Slack won't complete the OAuth install without a bot user, and a bot user needs at least one scope. The bot scopes mirror the user scopes minus `search:read` (which isn't valid for bot tokens — putting it there causes validation errors in Slack's manifest editor). In practice, Amicode only uses the **User OAuth Token** (`xoxp-…`). + +**Why `search:read` is user-only:** +Slack's API restricts `search:read` to user tokens. If you add it under `bot` scopes, the manifest editor shows red validation errors on every bot scope line and the install fails. + +## Step 2: Install and get the User OAuth Token + +1. After creating the app, click **Install to Workspace** and authorize +2. Go to **App Settings** → **OAuth & Permissions** +3. Copy the **User OAuth Token** (starts with `xoxp-`) + +You do **not** need the Bot Token (`xoxb-`) or App Token (`xapp-`) — those are for the idle bot user. + +## Step 3: Register the OAuth redirect URI + +When you run `amico slack login`, it starts a local HTTP server to receive the OAuth callback. Slack rejects callbacks to unregistered URIs. + +1. In App Settings → **OAuth & Permissions** → **Redirect URLs**, add: + ``` + http://localhost:54213/callback + ``` +2. Click **Save URLs** + +> **Note:** The port (`54213`) may vary between login attempts. If you see a `redirect_uri did not match` error with a different port, add that port's callback URL as well. + +## Step 4: Wire the MCP server into Amicode + +Add a `slack` entry under `mcp` in your Amicode config (`~/.config/opencode/opencode.json`): + +```json +{ + "mcp": { + "slack": { + "type": "local", + "command": "npx", + "args": ["-y", "slack-mcp-server"], + "env": { + "SLACK_TOKEN": "" + } + } + } +} +``` + +Replace `` with the token from Step 2. + +The MCP package is **`slack-mcp-server`** by korotovsky (not `@anthropic/slack-mcp-server`). + +## Step 5: Reload and verify + +1. Reload the Amicode window: **Cmd+Shift+P** → **Developer: Reload Window** +2. Open a **new chat session** (the + button) +3. Ask Amico to send a test message, e.g.: *"message @someone in Slack saying hello"* + +If the MCP server loaded correctly, Amico will have access to tools like `users_search`, `conversations_add_message`, `conversations_history`, and `channels_list`. + +## Available Slack tools + +Once connected, these tools are available in every session: + +| Task | Tool | Key parameters | +|------|------|----------------| +| Read channel history | `conversations_history` | `channel_id`, `limit` | +| Read thread replies | `conversations_replies` | `channel_id`, `thread_ts`, `limit` | +| Send a message | `conversations_add_message` | `channel_id`, `text`, `thread_ts` (optional) | +| Search messages | `conversations_search_messages` | `query`, `sort` | +| List channels | `channels_list` | `limit` | +| Find users | `users_search` | `query` | +| Get unread messages | `conversations_unreads` | — | +| Add/remove reaction | `reactions_add` / `reactions_remove` | `channel`, `timestamp`, `name` | + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| Install fails silently ("Click Create and Install to try again") | Make sure the manifest has a `bot_user` block and `is_hosted: false` in settings | +| Red squiggles on all bot scopes in manifest editor | Remove `search:read` from `bot` scopes — it's user-token-only | +| `redirect_uri did not match` error on login | Add the exact `http://localhost:/callback` URL from the error to OAuth & Permissions → Redirect URLs | +| Slack tools not available in session | Check that the `mcp.slack` block is in `~/.config/opencode/opencode.json`, then reload the window and start a **new** session | +| Messages sent as bot instead of as you | Make sure you're using the User OAuth Token (`xoxp-`), not the Bot Token (`xoxb-`) | + +## File reference + +| File | Location | Purpose | +|------|----------|---------| +| `slack-manifest.json` | This directory | Slack app manifest — paste into api.slack.com to create the app | +| `SKILL.md` | This directory | The amico-slack skill — tool reference and formatting rules | +| `opencode.json` | `~/.config/opencode/opencode.json` | Amicode config — where the MCP server is registered | diff --git a/packages/extension/skills/amico-slack/slack-manifest.json b/packages/extension/skills/amico-slack/slack-manifest.json new file mode 100644 index 000000000..12def8ece --- /dev/null +++ b/packages/extension/skills/amico-slack/slack-manifest.json @@ -0,0 +1,46 @@ +{ + "display_information": { + "name": "Amicode" + }, + "features": { + "bot_user": { + "display_name": "Amicode", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "channels:history", + "channels:read", + "groups:history", + "groups:read", + "im:history", + "im:read", + "mpim:history", + "mpim:read", + "chat:write", + "users:read" + ], + "user": [ + "channels:history", + "channels:read", + "groups:history", + "groups:read", + "im:history", + "im:read", + "mpim:history", + "mpim:read", + "chat:write", + "search:read", + "users:read" + ] + } + }, + "settings": { + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "is_hosted": false, + "token_rotation_enabled": false + } +} From 6ed47edc8e65e0d758dc6887a4f106babfca9754 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 20 Sep 2026 15:13:08 -0400 Subject: [PATCH 02/80] docs(skills): rewire the spec-reviewer prompt + analyze off the dead P-list model The amicode tail of the D6 consumer sweep (amico-strategy was #1317's half; armonissa #105/PR #106 carries the vault half). The spec-reviewer's conflict criterion now checks the live direction surface (PI-owned INTENT + conventions); the analyze skill triggers on 'direction needs informing' with the D6 routing inline, and its Strategy Suggestions output becomes Direction Suggestions reading the strategy brief. Zero live P-list references remain in amicode skills. --- packages/extension/skills/analyze/SKILL.md | 4 ++-- .../extension/skills/brainstorming/spec-reviewer-prompt.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/extension/skills/analyze/SKILL.md b/packages/extension/skills/analyze/SKILL.md index 91c711286..b69c40a33 100644 --- a/packages/extension/skills/analyze/SKILL.md +++ b/packages/extension/skills/analyze/SKILL.md @@ -17,7 +17,7 @@ Post-experiment analysis for Amico optimization results. ## Instructions ### When to Run -After a batch of optimization experiments (3+), or when STRATEGY.md priorities need updating. +After a batch of optimization experiments (3+), or when research direction needs informing (route findings via the D6 rules: survey-shaped → the hopper with a triage tag; intent-direction → the amicissimo proposals surface — agents never edit INTENT). ### Step 1: Gather Data - Read recent experiment notes in `/ledger/observations/` @@ -54,7 +54,7 @@ Trace chains via `warm_started_from` in catalog: ### Step 6: Generate Outputs - **Insight notes**: Create in `/ledger/observations/` when patterns are clear (3+ supporting experiments) -- **Strategy suggestions**: Note which STRATEGY.md priorities are progressing vs stuck +- **Direction suggestions**: Note which direction tiers are progressing vs stalled (from the strategy brief — `amico-run strategy-brief`); route them per the D6 rules (hopper triage tag, or the proposals surface for intent-direction items) > **Fidelity convention:** Always report both fixed-phase and free-phase fidelity for multi-subsystem gates — free-phase is the primary metric, and fixed-phase can underreport substantially for entangling gates. The `setup` skill owns the canonical statement of this convention (the quantified gap and its reference) — consult it rather than restating it here. diff --git a/packages/extension/skills/brainstorming/spec-reviewer-prompt.md b/packages/extension/skills/brainstorming/spec-reviewer-prompt.md index 8ee53dfc3..b7d405901 100644 --- a/packages/extension/skills/brainstorming/spec-reviewer-prompt.md +++ b/packages/extension/skills/brainstorming/spec-reviewer-prompt.md @@ -5,7 +5,7 @@ You are reviewing a technical specification for a quantum optimal control resear ## Evaluate on these criteria: 1. **Completeness** — Could an implementer pick up each section and know exactly what to write? 2. **Consistency** — Do the pieces fit together? Are field names, schemas, and conventions aligned? -3. **Conflicts** — Does anything contradict existing architecture (agent definitions, STRATEGY.md conventions)? +3. **Conflicts** — Does anything contradict existing architecture (agent definitions, the live direction surface — PI-owned INTENT and its conventions)? 4. **Edge cases** — What happens when inputs are empty, missing, or invalid? 5. **Scope** — Is anything included that shouldn't be, or missing that should be? 6. **Testability** — Are success criteria measurable? From bead5cce8c0b2572c2a206fec9e3b091cae03788 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 20 Sep 2026 15:22:29 -0400 Subject: [PATCH 03/80] =?UTF-8?q?feat(packs):=20the=20pack=20lint=20+=20th?= =?UTF-8?q?e=20two=20seeded=20domain=20packs=20=E2=80=94=20EE/test-&-measu?= =?UTF-8?q?rement=20and=20sensing/metrology?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Seeded' means something now (plan-20260920 step 10): - The pack schema gains the domain keys (additive, optional at the schema layer so the flagship stays valid): curricula, payload_schemas, verification (contract reference + claim-class bindings), instruments (with the coverage scope bound), benchmarks, first_task (with the shakedown/substrate level ladder). scores minItems relaxed deliberately: a seeded domain pack legitimately carries no composed scores yet — the quantum-era single-pack contract, not the general one (test updated to the new contract, reason inline). - The pack lint: schema validation PLUS reference resolution (dangling refs = named findings) PLUS the seeded-key check — the flagship reads FORMALIZED (missing first_task), exactly the grandfathered design, and its snapshot-layout skill paths surface as the known real finding the step-14 formalization must reconcile (baked into the test so the reconciliation cannot land silently). - The two seeded packs, each with a verification contract (tier assignments, claim-class bindings), the payloads doc, the first-task declaration (shakedown-marked, NOT yet run — exercised is a run state, never a manifest state), a curriculum skeleton authored to be filled from measured practice, and corrector integrity manifests (load-time shas, the #369 pattern — found by running the loader). Verified: pack_lint 4/4 (EE + sensing seeded; flagship formalized with the known finding); scores suite 323/323; schema 284/284; packaging + gate_packs 65 passed. Loader loads all three packs clean (errors: []). Part of amicode #1328 (plan-20260920-general-research-engine step 10). --- .../packs/ee-test-measurement/FIRST-TASK.md | 24 +++ .../packs/ee-test-measurement/PACK.toml | 53 ++++++ .../packs/ee-test-measurement/PAYLOADS.md | 10 + .../packs/ee-test-measurement/VERIFICATION.md | 21 +++ .../ee-test-measurement/benchmarks/README.md | 6 + .../corrector/integrity.toml | 6 + .../instrument-bringup/CURRICULUM.md | 19 ++ .../packs/sensing-metrology/FIRST-TASK.md | 17 ++ .../packs/sensing-metrology/PACK.toml | 45 +++++ .../packs/sensing-metrology/PAYLOADS.md | 8 + .../packs/sensing-metrology/VERIFICATION.md | 13 ++ .../sensing-metrology/benchmarks/README.md | 5 + .../corrector/integrity.toml | 6 + .../noise-spectroscopy/CURRICULUM.md | 18 ++ packages/extension/src/scores/pack_lint.ts | 133 +++++++++++++ packages/extension/test/pack_lint.test.ts | 104 +++++++++++ packages/schema/schemas/pack.schema.json | 174 +++++++++++++++--- packages/schema/test/pack.test.ts | 8 +- 18 files changed, 647 insertions(+), 23 deletions(-) create mode 100644 packages/extension/packs/ee-test-measurement/FIRST-TASK.md create mode 100644 packages/extension/packs/ee-test-measurement/PACK.toml create mode 100644 packages/extension/packs/ee-test-measurement/PAYLOADS.md create mode 100644 packages/extension/packs/ee-test-measurement/VERIFICATION.md create mode 100644 packages/extension/packs/ee-test-measurement/benchmarks/README.md create mode 100644 packages/extension/packs/ee-test-measurement/corrector/integrity.toml create mode 100644 packages/extension/packs/ee-test-measurement/curricula/instrument-bringup/CURRICULUM.md create mode 100644 packages/extension/packs/sensing-metrology/FIRST-TASK.md create mode 100644 packages/extension/packs/sensing-metrology/PACK.toml create mode 100644 packages/extension/packs/sensing-metrology/PAYLOADS.md create mode 100644 packages/extension/packs/sensing-metrology/VERIFICATION.md create mode 100644 packages/extension/packs/sensing-metrology/benchmarks/README.md create mode 100644 packages/extension/packs/sensing-metrology/corrector/integrity.toml create mode 100644 packages/extension/packs/sensing-metrology/curricula/noise-spectroscopy/CURRICULUM.md create mode 100644 packages/extension/src/scores/pack_lint.ts create mode 100644 packages/extension/test/pack_lint.test.ts diff --git a/packages/extension/packs/ee-test-measurement/FIRST-TASK.md b/packages/extension/packs/ee-test-measurement/FIRST-TASK.md new file mode 100644 index 000000000..72f0c7195 --- /dev/null +++ b/packages/extension/packs/ee-test-measurement/FIRST-TASK.md @@ -0,0 +1,24 @@ +# First task — the shakedown exercise (declared; NOT yet run) + +**The task:** one non-QICK instrument class through the strumento device +schema against **mock instruments** — a signal-generator + spectrum-analyzer +fit cycle via the task-record seam: author a device instance for the bench +class (schema extension in scope this pass), run a fit cycle +(`instrumento fit lorentzian`-class) against a mock measurement, record the +task (manifest, progress stream, terminal result), and fold a **T1b, +tier-labeled verdict** (residuals against declared tolerance) end-to-end +through the loop. + +**Exercise level: shakedown** — the loop plumbing end-to-end on synthetic +substrate. It certifies the pack's mechanics (schema → task record → +verdict → fold), not the domain's real capability. Substrate-touching +exercise (a real bench run) is the tracked next milestone, gated on +hardware access that is not wired in this build — stated, not assumed. + +**The polyglot seam rides the same exercise:** the task record the mock run +produces is written by the Python task-record writer and read by a Julia +consumer — one round-trip exercising the contract's shape across languages. + +**Exit:** the run exists with a run ID, a tier-labeled verdict, and the task +directory intact — then this pack is EXERCISED (shakedown), and this +declaration updates to point at the run. diff --git a/packages/extension/packs/ee-test-measurement/PACK.toml b/packages/extension/packs/ee-test-measurement/PACK.toml new file mode 100644 index 000000000..b9658c25e --- /dev/null +++ b/packages/extension/packs/ee-test-measurement/PACK.toml @@ -0,0 +1,53 @@ +# The EE / test-&-measurement domain pack (plan-20260920 step 10; amicode #1328). +# The strumento substrate — device model, task records, calibration store — +# is this domain in quantum clothing; this pack generalizes it to instruments. +# SEEDED, not exercised: the manifest + references resolve; the first task +# (a mock-instrument fit cycle) is the shakedown exercise, the next milestone. +# +# SCOPE BOUND (honest): instruments the device schema actually models. The +# schema today models QICK-class hardware; the first task extends it to the +# bench signal-generator + spectrum-analyzer class (mock). Everything the +# schema does NOT model is out of scope until modeled — stated, never assumed. +schema_version = "1" +id = "ee-test-measurement" +name = "Electrical Engineering / Test & Measurement" +version = 1 +scores = [] +curricula = ["curricula/instrument-bringup/CURRICULUM.md"] +skills = [] # the strumento skill generalizes at exercise time; none shipped yet +payload_schemas = ["PAYLOADS.md"] +benchmarks = ["benchmarks/README.md"] + +[[instruments]] +kind = "strumento device schema (QICK-class, existing)" +coverage = "modeled" + +[[instruments]] +kind = "bench signal generator (mock, schema extension this pass)" +coverage = "modeled-at-first-task" + +[[instruments]] +kind = "spectrum analyzer (mock, schema extension this pass)" +coverage = "modeled-at-first-task" + +[verification] +contract = "VERIFICATION.md" +bindings = [ + "measurement residuals: T1b (deterministic given the recorded artifact; residuals against declared tolerance, honestly noise-bounded)", + "closed-loop recalibration: T1b over the recorded calibration-store chain", + "hardware claims: un-tiered until measured on real instruments (mock runs certify nothing about hardware)", +] + +[first_task] +declaration = "FIRST-TASK.md" +level = "shakedown" + +[onboarding] +primary = "instrument-bringup" +head = "instrument-bringup" + +[corrector] +name = "task-record contract (the strumento seam: the directory is the truth; verdicts re-derived from the record)" +paths = ["FIRST-TASK.md", "VERIFICATION.md"] +integrity = "corrector/integrity.toml" +tier = "open" diff --git a/packages/extension/packs/ee-test-measurement/PAYLOADS.md b/packages/extension/packs/ee-test-measurement/PAYLOADS.md new file mode 100644 index 000000000..41f494c78 --- /dev/null +++ b/packages/extension/packs/ee-test-measurement/PAYLOADS.md @@ -0,0 +1,10 @@ +# EE / test-&-measurement — typed payloads + +The pack's payload vocabulary (additive; the schema package is the arbiter): + +- `measurement-result` — {quantity, value, tolerance, artifact-ref, provenance} — T1b-verified residuals against the recorded artifact +- `calibration-ref-advance` — {ref, parent, config-content-id, per-key-diffs} — the calibration store's content-addressed chain +- `task-record-terminal` — {state, error_kind (script | transport — who fixes them), gates-forwarded, authored_by} — the strumento seam + +Every payload carries `tier` (T1a | T1b | T2 | T3 | un-tiered | pre-tier) — +the enum travels with the claim through every render path. diff --git a/packages/extension/packs/ee-test-measurement/VERIFICATION.md b/packages/extension/packs/ee-test-measurement/VERIFICATION.md new file mode 100644 index 000000000..f78994cbd --- /dev/null +++ b/packages/extension/packs/ee-test-measurement/VERIFICATION.md @@ -0,0 +1,21 @@ +# EE / test-&-measurement — the verification contract + +Tiered and claim-class-bound (spec-20260920-171500 D4): a tier tag asserts +what the instrument establishes, never more. + +| Claim class | Tier | The gate | +|---|---|---| +| Measurement residuals (recorded artifact vs model, declared tolerance) | **T1b** | deterministic given the recorded artifact — the measurement is stochastic, the residual check over the recorded artifact is not; tolerance declared per contract, residuals honestly noise-bounded | +| Closed-loop recalibration (the measure → believe → measure-again chain) | **T1b** | the calibration store's content-addressed chain: each ref advance re-derived from the record, never self-graded | +| Hardware claims (real instruments) | **un-tiered** | mock runs certify NOTHING about hardware — the tier appears only when real instruments are measured; the scope bound holds | +| Bringup state (device model vs manifest) | **T1b** | the strumento device-schema validation (typed errors, `ok: false` carries the error class) | + +**The re-tiering rule:** upgrade on new mechanical evidence (provenance +stamped); downgrade/quarantine when an instrument is invalidated, cascading +to the claims it certified. Historical records render `pre-tier` with +provenance until retro-labeled via the deterministic class→tier table. + +**The task-record contract is the trust boundary:** the directory is the +truth; verdicts forwarded from gates are re-derived from the record before +routing; only declared gates forward at all; forwarding requires a +non-empty `authored_by`. diff --git a/packages/extension/packs/ee-test-measurement/benchmarks/README.md b/packages/extension/packs/ee-test-measurement/benchmarks/README.md new file mode 100644 index 000000000..9cf6c22a5 --- /dev/null +++ b/packages/extension/packs/ee-test-measurement/benchmarks/README.md @@ -0,0 +1,6 @@ +# EE / test-&-measurement — benchmarks + +Declared, not yet populated (honest v1): the benchmark surface lands with +the first-task exercise — the fit-cycle run's measured table (residuals, +wall-clock, retries per fit kind) is the first bench row, cited with its +run ID. Performance and quality claims cite bench rows, never adjectives. diff --git a/packages/extension/packs/ee-test-measurement/corrector/integrity.toml b/packages/extension/packs/ee-test-measurement/corrector/integrity.toml new file mode 100644 index 000000000..db243a1ce --- /dev/null +++ b/packages/extension/packs/ee-test-measurement/corrector/integrity.toml @@ -0,0 +1,6 @@ +# sha256 integrity manifest for the corrector paths (relative to the pack dir). +# Verified at load time (WS1 #369 pattern): a mismatch makes the pack BROKEN. +# Regenerate: shasum -a 256 from the pack dir. +[files] +"FIRST-TASK.md" = "971f100a28f673ccabd73a0662abd58f4dd14c6bcf2d5e4b123febc526891547" +"VERIFICATION.md" = "3d53456c5a60b266cf79e3d0b09e6f634b6bb3439871f415c0730f706d895eb2" diff --git a/packages/extension/packs/ee-test-measurement/curricula/instrument-bringup/CURRICULUM.md b/packages/extension/packs/ee-test-measurement/curricula/instrument-bringup/CURRICULUM.md new file mode 100644 index 000000000..460675001 --- /dev/null +++ b/packages/extension/packs/ee-test-measurement/curricula/instrument-bringup/CURRICULUM.md @@ -0,0 +1,19 @@ +--- +type: curriculum +schema_version: "1" +id: instrument-bringup +version: 1 +description: >- + The skeleton seed for the EE domain's guided path: device-model definition, + mock bringup, the fit cycle, the task-record discipline. Stages are + placeholders until the first-task exercise informs them — a curriculum is + authored from measured practice, not speculation. +stages: [] # deliberately empty: the skeleton exists so the manifest resolves; content lands with exercise +memory_hooks: [] # none yet — hook-card genesis proposes them from recurring questions +--- + +# Instrument bringup (skeleton) + +The guided path from a device description to a verified fit cycle. Authored +from the first-task exercise when it runs — the curriculum grows from what +the loop actually needed, not from a speculative plan. diff --git a/packages/extension/packs/sensing-metrology/FIRST-TASK.md b/packages/extension/packs/sensing-metrology/FIRST-TASK.md new file mode 100644 index 000000000..58145fd6b --- /dev/null +++ b/packages/extension/packs/sensing-metrology/FIRST-TASK.md @@ -0,0 +1,17 @@ +# First task — the shakedown exercise (declared; NOT yet run) + +**The task:** filter-function validation against synthetic noise spectra — +compute the filter response for a representative filter-function family +against declared synthetic spectra, fold the verdict with confidence +intervals (the estimation payload) end-to-end through the loop, tier-labeled +T1b. The synthetic ground truth is declared up front: the harness compares +deterministically over it. + +**Exercise level: shakedown** — loop plumbing on synthetic substrate; it +certifies the pack's mechanics, not the domain's real capability. +Substrate-touching exercise (real NV data) is the tracked next milestone — +the NV testbed is not wired in this build, stated plainly. + +**Exit:** the run exists with a run ID, a tier-labeled verdict with CIs, +and the artifacts intact — then this pack is EXERCISED (shakedown), and this +declaration updates to point at the run. diff --git a/packages/extension/packs/sensing-metrology/PACK.toml b/packages/extension/packs/sensing-metrology/PACK.toml new file mode 100644 index 000000000..933d460db --- /dev/null +++ b/packages/extension/packs/sensing-metrology/PACK.toml @@ -0,0 +1,45 @@ +# The sensing / metrology domain pack (plan-20260920 step 10; amicode #1328). +# The NV-center substrate is the existing exploratory bet (INTENT's +# exploratory tier); this pack seeds the domain: filter functions, noise +# spectroscopy, metrology-grade estimation with confidence intervals. +# SEEDED, not exercised: the first task (filter-function validation against +# synthetic noise spectra) is the shakedown exercise, the next milestone. +schema_version = "1" +id = "sensing-metrology" +name = "Sensing & Metrology" +version = 1 +scores = [] +curricula = ["curricula/noise-spectroscopy/CURRICULUM.md"] +skills = [] # the sensing physics rides the flagship's Lindblad/filter-function material at exercise time +payload_schemas = ["PAYLOADS.md"] +benchmarks = ["benchmarks/README.md"] + +[[instruments]] +kind = "NV-center testbed (data substrate, not wired in this build)" +coverage = "unmodeled-until-hardware" + +[[instruments]] +kind = "synthetic noise spectra (validation substrate)" +coverage = "modeled" + +[verification] +contract = "VERIFICATION.md" +bindings = [ + "filter-function validation vs synthetic spectra: T1b (exit-code + artifact contract — the validation harness over the synthetic ground truth)", + "estimation results (noise spectral density, filter response): T1b with CI folding — the estimation payload carries lo/hi bounds and the method tag", + "real-testbed claims: un-tiered until real data is measured", +] + +[first_task] +declaration = "FIRST-TASK.md" +level = "shakedown" + +[onboarding] +primary = "noise-spectroscopy" +head = "noise-spectroscopy" + +[corrector] +name = "synthetic-spectra ground truth (the validation harness: declared spectra, deterministic comparison)" +paths = ["FIRST-TASK.md", "VERIFICATION.md"] +integrity = "corrector/integrity.toml" +tier = "open" diff --git a/packages/extension/packs/sensing-metrology/PAYLOADS.md b/packages/extension/packs/sensing-metrology/PAYLOADS.md new file mode 100644 index 000000000..6925f95f1 --- /dev/null +++ b/packages/extension/packs/sensing-metrology/PAYLOADS.md @@ -0,0 +1,8 @@ +# Sensing / metrology — typed payloads + +- `estimation-result` — {quantity, value, lo, hi, method, artifact-ref, provenance} — the CI-folding payload (the EstimationResult class) +- `validation-verdict` — {claim, tier (T1b), ground-truth-ref, delta, tolerance} — the synthetic-spectra comparison +- `filter-function-artifact` — {family, parameters, response-array-ref, sha} — the computed response, content-addressed + +Every payload carries `tier` — the enum travels with the claim (un-tiered +for real-testbed claims until measured; pre-tier for historical records). diff --git a/packages/extension/packs/sensing-metrology/VERIFICATION.md b/packages/extension/packs/sensing-metrology/VERIFICATION.md new file mode 100644 index 000000000..8b6d5cf52 --- /dev/null +++ b/packages/extension/packs/sensing-metrology/VERIFICATION.md @@ -0,0 +1,13 @@ +# Sensing / metrology — the verification contract + +Tiered and claim-class-bound (spec-20260920-171500 D4): + +| Claim class | Tier | The gate | +|---|---|---| +| Filter-function validation vs synthetic spectra | **T1b** | exit-code + artifact contract: the validation harness compares computed filter response against the declared synthetic ground truth — deterministic over the declared spectra | +| Estimation results (noise spectral density, filter response, with CI) | **T1b** | the estimation payload carries value + lo/hi bounds + the method tag; the CI folds into the ledger as an EstimationResult-class payload | +| Real-testbed claims (NV hardware) | **un-tiered** | synthetic validation certifies NOTHING about the real testbed; the tier appears only when real data is measured | +| Metrology-grade comparisons (cross-method) | **T1b** | reproducible comparison over recorded artifacts; the better method wins on declared metrics, never vibes | + +**The re-tiering rule** and the `pre-tier`/retro-labeling discipline: as in +the EE contract (spec-20260920 D4, one rule for every domain). diff --git a/packages/extension/packs/sensing-metrology/benchmarks/README.md b/packages/extension/packs/sensing-metrology/benchmarks/README.md new file mode 100644 index 000000000..ddfd7e3a9 --- /dev/null +++ b/packages/extension/packs/sensing-metrology/benchmarks/README.md @@ -0,0 +1,5 @@ +# Sensing / metrology — benchmarks + +Declared, not yet populated (honest v1): the first bench row is the +first-task validation's measured table (filter families × spectra shapes × +deltas vs tolerances), cited with its run ID. diff --git a/packages/extension/packs/sensing-metrology/corrector/integrity.toml b/packages/extension/packs/sensing-metrology/corrector/integrity.toml new file mode 100644 index 000000000..8bdf1679f --- /dev/null +++ b/packages/extension/packs/sensing-metrology/corrector/integrity.toml @@ -0,0 +1,6 @@ +# sha256 integrity manifest for the corrector paths (relative to the pack dir). +# Verified at load time (WS1 #369 pattern): a mismatch makes the pack BROKEN. +# Regenerate: shasum -a 256 from the pack dir. +[files] +"FIRST-TASK.md" = "e75ac866f0a60eaf4852422d0083ab5806b8f66cf1549472b33748f845c94cfa" +"VERIFICATION.md" = "fcfc12df1318989ee034a38d0483a9ca3e36fa3563a989982f550d673f0eba06" diff --git a/packages/extension/packs/sensing-metrology/curricula/noise-spectroscopy/CURRICULUM.md b/packages/extension/packs/sensing-metrology/curricula/noise-spectroscopy/CURRICULUM.md new file mode 100644 index 000000000..40b69584e --- /dev/null +++ b/packages/extension/packs/sensing-metrology/curricula/noise-spectroscopy/CURRICULUM.md @@ -0,0 +1,18 @@ +--- +type: curriculum +schema_version: "1" +id: noise-spectroscopy +version: 1 +description: >- + The skeleton seed for the sensing domain's guided path: filter-function + selection, noise-spectroscopy estimation, metrology-grade comparison. + Stages are placeholders until the first-task exercise informs them — + authored from measured practice, not speculation. +stages: [] +memory_hooks: [] +--- + +# Noise spectroscopy (skeleton) + +The guided path from a sensing question to a CI-bounded estimation. Authored +from the first-task exercise when it runs. diff --git a/packages/extension/src/scores/pack_lint.ts b/packages/extension/src/scores/pack_lint.ts new file mode 100644 index 000000000..e24305b37 --- /dev/null +++ b/packages/extension/src/scores/pack_lint.ts @@ -0,0 +1,133 @@ +// The pack lint (plan-20260920 step 10; amicode #1328) — "seeded" means +// something. The loader (packs.ts) loads and reports; the LINT is the bar the +// acceptance reads: schema validation PLUS reference resolution PLUS the +// domain key set. A pack counts as SEEDED when its manifest validates, every +// path-bearing reference resolves, and it carries the full domain keys +// (curricula, payload schemas, verification contract with claim-class +// bindings, instruments, benchmarks, first task). The flagship quantum pack +// carries no first task by design — it reads FORMALIZED, not seeded (the +// grandfathered status, spec-20260920 D2/D7). +// +// A manifest is cheap; a domain is not (the r3 advisory, verbatim): the +// exercised bar — the first task run end-to-end through the campaign loop +// with a tier-labeled verdict — is a run state, never a manifest state; the +// lint records the declared level (shakedown | substrate) and stops there, +// honestly. +import * as fs from "node:fs"; +import * as path from "node:path"; +import { validateFile } from "@amicode/schema"; +import { parse as parseToml } from "smol-toml"; + +export interface PackLintFinding { + kind: "schema" | "dangling-ref" | "missing-domain-key"; + detail: string; +} + +export interface PackLintResult { + dir: string; + id: string; + seeded: boolean; + exerciseLevel: "shakedown" | "substrate" | undefined; + findings: PackLintFinding[]; +} + +/** The domain key set — required for SEEDED, optional at the schema layer so + * the flagship's WS1 manifest stays valid without them. */ +export const DOMAIN_KEYS = [ + "curricula", + "payload_schemas", + "verification", + "instruments", + "benchmarks", + "first_task", +] as const; + +interface PathBearing { + field: string + p: string +} + +function collectReferences(manifest: Record): PathBearing[] { + const refs: PathBearing[] = [] + const rel = (field: string, v: unknown) => { + if (typeof v === "string") refs.push({ field, p: v }) + } + for (const s of (manifest["scores"] as string[]) ?? []) rel("scores", s) + for (const sk of (manifest["skills"] as { path: string }[]) ?? []) rel("skills", sk.path) + const templates = manifest["templates"] as Record | undefined + if (templates) for (const t of Object.values(templates)) rel("templates", t.path) + const corrector = manifest["corrector"] as { paths: string[]; integrity: string } | undefined + if (corrector) { + for (const c of corrector.paths ?? []) rel("corrector.paths", c) + rel("corrector.integrity", corrector.integrity) + } + for (const c of (manifest["curricula"] as string[]) ?? []) rel("curricula", c) + for (const c of (manifest["payload_schemas"] as string[]) ?? []) rel("payload_schemas", c) + for (const b of (manifest["benchmarks"] as string[]) ?? []) rel("benchmarks", b) + const verification = manifest["verification"] as { contract: string } | undefined + if (verification) rel("verification.contract", verification.contract) + const firstTask = manifest["first_task"] as { declaration: string } | undefined + if (firstTask) rel("first_task.declaration", firstTask.declaration) + return refs +} + +/** Lint one pack dir. Never throws: findings are the interface. */ +export function lintPackDir(dir: string): PackLintResult { + const manifestPath = path.join(dir, "PACK.toml") + const findings: PackLintFinding[] = [] + + if (!fs.existsSync(manifestPath)) { + return { dir, id: "", seeded: false, exerciseLevel: undefined, findings: [{ kind: "schema", detail: "no PACK.toml" }] } + } + + const validation = validateFile(manifestPath, "pack") + if (!validation.ok) { + findings.push({ kind: "schema", detail: validation.errors.join("; ") }) + } + + let manifest: Record = {} + try { + manifest = parseToml(fs.readFileSync(manifestPath, "utf8")) as Record + } catch (e) { + findings.push({ kind: "schema", detail: `unparsable TOML: ${e instanceof Error ? e.message : String(e)}` }) + return { dir, id: "", seeded: false, exerciseLevel: undefined, findings } + } + + // Reference resolution: every path-bearing field resolves, dangling = named. + for (const { field, p } of collectReferences(manifest)) { + const resolved = path.resolve(dir, p) + if (!fs.existsSync(resolved)) { + findings.push({ kind: "dangling-ref", detail: `${field}: ${p} does not resolve (${resolved})` }) + } + } + + // The seeded check: full domain key set AND a clean manifest. + for (const key of DOMAIN_KEYS) { + if (!(key in manifest)) { + findings.push({ kind: "missing-domain-key", detail: `${key} absent — not a seeded domain pack (formalized/legacy packs omit it by design)` }) + } + } + const exerciseLevel = (manifest["first_task"] as { level?: "shakedown" | "substrate" } | undefined)?.level + + return { + dir, + id: String(manifest["id"] ?? ""), + seeded: findings.length === 0, + exerciseLevel, + findings, + } +} + +/** Lint every pack under the ordered roots — the acceptance's validator. */ +export function lintPacks(roots: string[]): PackLintResult[] { + const results: PackLintResult[] = [] + for (const root of roots) { + if (!fs.existsSync(root)) continue + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const dir = path.join(root, entry.name) + if (fs.existsSync(path.join(dir, "PACK.toml"))) results.push(lintPackDir(dir)) + } + } + return results +} diff --git a/packages/extension/test/pack_lint.test.ts b/packages/extension/test/pack_lint.test.ts new file mode 100644 index 000000000..ed088a3f6 --- /dev/null +++ b/packages/extension/test/pack_lint.test.ts @@ -0,0 +1,104 @@ +// The pack-lint tests (plan-20260920 step 10; amicode #1328) — "seeded" +// means something, and these tests are the acceptance's own validator: +// they lint the REAL bundled packs. The EE and sensing packs must be SEEDED +// (clean manifest, every reference resolving, the full domain key set); the +// flagship quantum pack must read FORMALIZED, not seeded (no first task, by +// the grandfathered design) — and its references must still all resolve. +import { describe, it, expect } from "vitest" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { lintPacks, lintPackDir, DOMAIN_KEYS } from "../src/scores/pack_lint.js" + +const bundledPacks = path.resolve(import.meta.dirname, "..", "packs") + +describe("the pack lint", () => { + it("lints the bundled packs: EE and sensing are SEEDED; the flagship is formalized, not seeded", () => { + const results = lintPacks([bundledPacks]) + const byId = new Map(results.map((r) => [r.id, r])) + expect(byId.get("ee-test-measurement")?.seeded).toBe(true) + expect(byId.get("sensing-metrology")?.seeded).toBe(true) + + const flagship = byId.get("quantum-control") + expect(flagship).toBeDefined() + expect(flagship?.seeded).toBe(false) // formalized, not seeded — the grandfathered design + // its missing-domain-key findings are the EXPECTED marker of formalized status… + const missing = flagship?.findings.filter((f) => f.kind === "missing-domain-key") + expect(missing?.length).toBe(DOMAIN_KEYS.length) + // schema-clean against the extended schema (the additive extension does not break the WS1 manifest) + expect(flagship?.findings.filter((f) => f.kind === "schema")).toEqual([]) + // KNOWN REAL FINDING, recorded: the flagship's skill paths are SNAPSHOT-LAYOUT + // paths (../../skills/transmon resolves in the VSIX/extension-snapshot layout + // where all skills coexist — the repo tree carries only the meta skills), so + // in-repo the lint flags them dangling. The step-14 formalization reconciles + // the path layout; until then this is the honest finding, baked into the test + // so the reconciliation cannot land silently. + const dangling = flagship?.findings.filter((f) => f.kind === "dangling-ref") + expect(dangling?.every((f) => f.detail.startsWith("skills:"))).toBe(true) + }) + + it("the seeded packs declare their exercise level honestly (shakedown, not substrate)", () => { + for (const id of ["ee-test-measurement", "sensing-metrology"]) { + const r = lintPacks([bundledPacks]).find((x) => x.id === id) + expect(r?.exerciseLevel).toBe("shakedown") // declared; NOT exercised — a run state, never a manifest state + } + }) + + it("a dangling reference fails the lint, named", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pack-lint-")) + fs.writeFileSync( + path.join(dir, "PACK.toml"), + [ + 'schema_version = "1"', + 'id = "broken"', + 'name = "Broken"', + "scores = []", + "[onboarding]", + 'primary = "none"', + "[corrector]", + 'name = "gate"', + 'paths = ["nope.md"]', + 'integrity = "nope.toml"', + "curricula = []", + "payload_schemas = []", + "instruments = []", + "benchmarks = []", + "[first_task]", + 'declaration = "also-nope.md"', + 'level = "shakedown"', + "", + ].join("\n"), + ) + const r = lintPackDir(dir) + expect(r.seeded).toBe(false) + const dangling = r.findings.filter((f) => f.kind === "dangling-ref") + expect(dangling.some((f) => f.detail.includes("corrector.paths"))).toBe(true) + expect(dangling.some((f) => f.detail.includes("first_task.declaration"))).toBe(true) + }) + + it("a manifest missing the domain keys reads formalized (the seeded-marker findings, not errors)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pack-lint-2-")) + fs.writeFileSync( + path.join(dir, "PACK.toml"), + [ + 'schema_version = "1"', + 'id = "minimal"', + 'name = "Minimal"', + "scores = []", + "[onboarding]", + 'primary = "none"', + "[corrector]", + 'name = "gate"', + 'paths = ["gate.md"]', + 'integrity = "integrity.toml"', + "", + ].join("\n"), + ) + fs.writeFileSync(path.join(dir, "gate.md"), "# the gate contract\n") + fs.writeFileSync(path.join(dir, "integrity.toml"), "schema_version = \"1\"\n") + const r = lintPackDir(dir) + expect(r.seeded).toBe(false) + // schema-clean and reference-clean — the ONLY findings are the domain-key markers + expect(r.findings.every((f) => f.kind === "missing-domain-key")).toBe(true) + }) +}) diff --git a/packages/schema/schemas/pack.schema.json b/packages/schema/schemas/pack.schema.json index 296a60c0e..f35abf091 100644 --- a/packages/schema/schemas/pack.schema.json +++ b/packages/schema/schemas/pack.schema.json @@ -2,33 +2,51 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://amico.harmoniqs.co/schema/pack/v1", "title": "amico pack manifest", - "description": "The unit of generality for a domain pack (autoresearch studio WS1, #369): one manifest per domain bundling skills, a template registry, the corrector with its integrity manifest, the catalog schema, the onboarding score, and an eval corpus pointer. Owned by the schema package; one validator consumed by the extension, the CLI, and CI. A score is one FIELD of a pack, not a peer of it. The per-entry tier (open vs an entitlement bundle id) encodes the open-core boundary: flowing a feature back to open-source is a manifest edit plus a content move, not a migration. Pack ids are deliberately NOT an enum — the taxonomy is manifest data, revisable without a schema version bump.", + "description": "The unit of generality for a domain pack (autoresearch studio WS1, #369): one manifest per domain bundling skills, a template registry, the corrector with its integrity manifest, the catalog schema, the onboarding score, and an eval corpus pointer. Owned by the schema package; one validator consumed by the extension, the CLI, and CI. A score is one FIELD of a pack, not a peer of it. The per-entry tier (open vs an entitlement bundle id) encodes the open-core boundary: flowing a feature back to open-source is a manifest edit plus a content move, not a migration. Pack ids are deliberately NOT an enum \u2014 the taxonomy is manifest data, revisable without a schema version bump.", "type": "object", "additionalProperties": false, - "required": ["schema_version", "id", "name", "scores", "onboarding", "corrector"], + "required": [ + "schema_version", + "id", + "name", + "scores", + "onboarding", + "corrector" + ], "properties": { - "schema_version": { "enum": ["1"] }, + "schema_version": { + "enum": [ + "1" + ] + }, "id": { "type": "string", "pattern": "^[a-z0-9-]+$", "description": "pack id, kebab-case, free-form (quantum-control, qec, calibration, ...)" }, - "name": { "type": "string", "minLength": 1 }, + "name": { + "type": "string", + "minLength": 1 + }, "version": { "type": "integer", "minimum": 1, - "description": "pack content version — bump when content moves between tiers" + "description": "pack content version \u2014 bump when content moves between tiers" }, "scores": { "type": "array", - "minItems": 1, - "items": { "type": "string", "minLength": 1 }, - "description": "score dirs this pack contributes, relative to the pack dir" + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Score/curriculum dirs (paths relative to the pack dir). A seeded domain pack may carry none yet \u2014 the curriculum skeleton rides `curricula` and composed interviews come at maturity." }, "onboarding": { "type": "object", "additionalProperties": false, - "required": ["primary"], + "required": [ + "primary" + ], "properties": { "primary": { "type": "string", @@ -44,32 +62,48 @@ }, "skills": { "type": "array", - "items": { "$ref": "#/$defs/tieredPath" }, + "items": { + "$ref": "#/$defs/tieredPath" + }, "description": "skill dirs this pack contributes, relative to the pack dir" }, "templates": { "type": "object", - "additionalProperties": { "$ref": "#/$defs/tieredPath" }, - "description": "template registry: name → tiered path (e.g. solve → the vetted template)" + "additionalProperties": { + "$ref": "#/$defs/tieredPath" + }, + "description": "template registry: name \u2192 tiered path (e.g. solve \u2192 the vetted template)" }, "corrector": { "type": "object", "additionalProperties": false, - "required": ["name", "paths", "integrity"], + "required": [ + "name", + "paths", + "integrity" + ], "properties": { - "name": { "type": "string", "minLength": 1 }, + "name": { + "type": "string", + "minLength": 1 + }, "paths": { "type": "array", "minItems": 1, - "items": { "type": "string", "minLength": 1 }, - "description": "the gate artifacts — must resolve OUTSIDE agent-editable trees (a load-time property, not a convention)" + "items": { + "type": "string", + "minLength": 1 + }, + "description": "the gate artifacts \u2014 must resolve OUTSIDE agent-editable trees (a load-time property, not a convention)" }, "integrity": { "type": "string", "minLength": 1, "description": "path to the sha256 integrity manifest covering every named path, relative to the pack dir" }, - "tier": { "$ref": "#/$defs/tier" } + "tier": { + "$ref": "#/$defs/tier" + } } }, "catalog_schema": { @@ -81,20 +115,118 @@ "type": "string", "minLength": 1, "description": "pointer to the pack's eval corpus (e.g. a Prova corpus ref)" + }, + "curricula": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Curriculum paths (relative to the pack dir). The curricula substrate is core; the content is pack-declared." + }, + "payload_schemas": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Typed-payload schema references (paths, relative to the pack dir)." + }, + "verification": { + "type": "object", + "required": [ + "contract", + "bindings" + ], + "properties": { + "contract": { + "type": "string", + "description": "Path to the per-domain verification contract (claim-class bindings, tier assignments)." + }, + "bindings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Claim-class bindings in short form (e.g. 'simulation: model-trajectory claims at T1a; hardware claims un-tiered') \u2014 the full contract doc carries the details." + } + }, + "description": "The verification-contract reference with claim-class bindings (the tiering is honest: tiers assert what the instrument establishes)." + }, + "instruments": { + "type": "array", + "items": { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string" + }, + "coverage": { + "type": "string", + "description": "modeled | modeled-at-first-task | unmodeled \u2014 the scope bound, stated not assumed" + } + } + }, + "description": "The instrument list with the scope bound: instruments the schema actually models \u2014 each entry states its coverage." + }, + "benchmarks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Benchmark paths (relative to the pack dir), each resolving at lint time." + }, + "first_task": { + "type": "object", + "required": [ + "declaration", + "level" + ], + "properties": { + "declaration": { + "type": "string", + "description": "Path to the first-task declaration doc." + }, + "level": { + "type": "string", + "enum": [ + "shakedown", + "substrate" + ], + "description": "shakedown = the loop plumbing end-to-end (mock/synthetic); substrate = touching real domain substrate. The exercised-to-maturity ladder is tracked per manifest." + } + }, + "description": "The first-task declaration \u2014 a pack is exercised when it has run end-to-end with a tier-labeled verdict." } }, "$defs": { "tier": { - "anyOf": [{ "const": "open" }, { "type": "string", "pattern": "^[a-z0-9-]+$" }], + "anyOf": [ + { + "const": "open" + }, + { + "type": "string", + "pattern": "^[a-z0-9-]+$" + } + ], "description": "open (default when absent), or the entitlement bundle id gating this entry" }, "tieredPath": { "type": "object", "additionalProperties": false, - "required": ["path"], + "required": [ + "path" + ], "properties": { - "path": { "type": "string", "minLength": 1 }, - "tier": { "$ref": "#/$defs/tier" } + "path": { + "type": "string", + "minLength": 1 + }, + "tier": { + "$ref": "#/$defs/tier" + } } } } diff --git a/packages/schema/test/pack.test.ts b/packages/schema/test/pack.test.ts index a214d8727..d5734047a 100644 --- a/packages/schema/test/pack.test.ts +++ b/packages/schema/test/pack.test.ts @@ -51,8 +51,12 @@ describe("the pack kind", () => { it("requires the onboarding primary score — a score is a FIELD of a pack", () => { expect(validate(pack({ onboarding: { head: "overture" } }), "pack").ok).toBe(false); }); - it("requires a non-empty scores list", () => { - expect(validate(pack({ scores: [] }), "pack").ok).toBe(false); + it("allows an empty scores list — the seeded-domain-pack contract (plan-20260920 step 10)", () => { + // A seeded domain pack legitimately carries no composed scores yet: the + // curriculum skeleton rides `curricula` and composed interviews come at + // maturity. The flagship keeps its non-empty list; minItems was the + // quantum-era single-pack contract, relaxed deliberately for domains. + expect(validate(pack({ scores: [] }), "pack").ok).toBe(true); }); it("requires corrector.integrity — the threshold condition is a load-time property, not a convention", () => { const c = { ...(pack().corrector as object) } as Record; From b3d5d268d625acb34e9e95c09e034bb2e606a7db Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 24 Sep 2026 02:16:25 -0400 Subject: [PATCH 04/80] docs(skills): route amico-schema-check escalation off the dead STRATEGY.md model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D6 sweep's enumeration counted the spec-reviewer prompt and the analyze skill as the last amicode-side dead-model refs, but amico-schema-check's escalation step still told agents to append to STRATEGY `## Agent Suggestions` (the line predates the sweep — it rode in with the library-absorb chore). Same D6 routing as the analyze rewrite: agents never edit the direction surface; the finding routes to the amicissimo proposals surface. --- packages/extension/skills/amico-schema-check/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/skills/amico-schema-check/SKILL.md b/packages/extension/skills/amico-schema-check/SKILL.md index aa38c4c79..f2b55ddab 100644 --- a/packages/extension/skills/amico-schema-check/SKILL.md +++ b/packages/extension/skills/amico-schema-check/SKILL.md @@ -78,7 +78,7 @@ These are ambiguous and reported for human review: ## Escalation -Ambiguous issues are logged to `dream-journal.md` with tag `needs-human-review`. If more than 5 unfixable issues are found, append to STRATEGY `## Agent Suggestions`: "Schema issues found in N notes — human review needed (see dream-journal)." +Ambiguous issues are logged to `dream-journal.md` with tag `needs-human-review`. If more than 5 unfixable issues are found, route the finding to the amicissimo proposals surface (agents never edit INTENT): "Schema issues found in N notes — human review needed (see dream-journal)." ## Output From cedaaf3b0322cff056b5a7ef7a87e5f64c031591 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 14:25:09 -0400 Subject: [PATCH 05/80] docs(paper-mode): ADR-0023 + glossary for human-in-the-loop LaTeX co-editing Records the Paper Mode design (epic #1620): a new committed-editing surface with the live Draft as source of truth, agent edits proposed as reviewable Pending Hunks, snapshot-base 3-way merge, per-hunk accept/reject with explicit conflict resolution, manual gated compile, and ADR-0017 ledger provenance for accepted hunks. Adds Paper Mode / Draft / Pending Hunk glossary terms. Refs #1620 --- CONTEXT.md | 12 +++++++++ ...023-paper-mode-snapshot-base-3way-merge.md | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 docs/adr/0023-paper-mode-snapshot-base-3way-merge.md diff --git a/CONTEXT.md b/CONTEXT.md index 63ea66c22..6bff9f0ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -245,6 +245,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/0023-paper-mode-snapshot-base-3way-merge.md b/docs/adr/0023-paper-mode-snapshot-base-3way-merge.md new file mode 100644 index 000000000..c679228d4 --- /dev/null +++ b/docs/adr/0023-paper-mode-snapshot-base-3way-merge.md @@ -0,0 +1,27 @@ +# 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. From 629eb3b7be30d3c006dc4e7101823fca11bee3bf Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 14:56:53 -0400 Subject: [PATCH 06/80] =?UTF-8?q?feat(skills):=20concise=20mode=20?= =?UTF-8?q?=E2=80=94=20toggleable=20output-shaping=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-invoked skill that shapes every response to lead with the answer/next action, cut preamble and closers, number multi-step work, cap visible lists, give concrete time estimates, and state errors matter-of-factly. Persists for the session; toggled off with "normal mode"/"stop concise". Never auto-triggers. Adapted (MIT) from ayghri/i-have-adhd, retuned for Amico's research-copilot context and gate/verification/persona invariants. Supersedes the response-presentation engine epic (#1330) for the 'toggle concise on/off' need. --- packages/extension/skills/concise/SKILL.md | 105 +++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/extension/skills/concise/SKILL.md diff --git a/packages/extension/skills/concise/SKILL.md b/packages/extension/skills/concise/SKILL.md new file mode 100644 index 000000000..2ec68fd91 --- /dev/null +++ b/packages/extension/skills/concise/SKILL.md @@ -0,0 +1,105 @@ +--- +name: concise +description: Toggle concise mode — shape every response to lead with the answer or next action, cut preamble and closers, number multi-step work, cap lists, and state errors matter-of-factly. Invoke ONLY when the user asks for concise/brief/terse output or says "concise mode"; it stays on for the rest of the session until they say "normal mode" or "stop concise". Never auto-trigger on ordinary turns. +agents: [] +surface: public +--- + +# concise + +Concise mode is on. Output is not just short — it is shaped so the reader can act +on it immediately: the answer first, the ceremony gone. + +Adapted from the MIT-licensed `i-have-adhd` skill by ayghri +(https://github.com/ayghri/i-have-adhd) for Amico's research-copilot context. + +## Persistence + +These rules apply to **every response for the rest of the session**, not only this +one. They do not expire after a few turns and they do not lapse when the topic +changes. If you are unsure whether they still apply, they do. + +Turn them off only when the reader says **"normal mode"** or **"stop concise"**. +Confirm in one line, then return to your default style. + +## Why this shape + +1. Working memory is small. Anything not on screen is forgotten — do not ask the reader to "keep in mind X." +2. Knowing the answer is not doing the answer. The gap between "got it" and "done it" is where work dies. +3. Starting is the hardest step. The first action must be obvious, small, and doable now. +4. Vague time estimates fail. "A bit of work" and "a few hours" register the same. +5. Buried wins do not register. Visible progress matters. + +## Rules + +### 1. Lead with the answer or next action +The first line is the answer, or something the reader can do — not context, not a plan. +If the answer is a command, path, or snippet, it goes first; prose comes after, if at all. + +Bad: "Let's think about this. Your solve has a few moving pieces…" +Good: "Pin the globals: set `fix_global_variable!(δ)` at `script.jl:42`, then re-run." + +### 2. Number multi-step tasks +More than one step → a numbered list, each step one bounded action, no step with "and then" twice. +Use the fewest steps that still work. A short path finished beats a complete path abandoned. + +### 3. End with one concrete next action +If anything is open, name ONE thing doable in under two minutes ("open the file" counts). + +Bad: "Hope that helps — let me know if you want to dig deeper." +Good: "Next: run the solve and paste the first `AMICODE_ITER` line." + +### 4. Suppress tangents +Finish the first issue, then offer the second as a separate question. A question that +comes up mid-work is not a tangent — answer it yourself if you can and fold it in; +if it still needs the reader, surface it once, at the end. + +### 5. Restate state every turn +The reader cannot hold "step 3 of 5" between messages. Restate it. If the harness has +a todo/plan tool, use it for multi-step work — one item in progress at a time — and let +the checklist do the restating instead of narrating the full plan as prose. + +### 6. Give specific time estimates, in concrete units +Bad: "This will take some work." Good: "~15 min if the template covers it; an afternoon if not." + +### 7. Make completed work visible +Show what now works, concretely. Do not bury the win in a recap. +Good: "Solve converged: `F = 0.9982` in 137 iterations. Pulse at `runs/…/pulse.jld2`." + +### 8. Matter-of-fact tone for errors +Never "Uh oh" / "Oh no" / "There seems to be a problem." State cause and fix. +Good: "Solve failed at `iter 12`: `PosDefException`. Cause: Hessian not PSD. Fix: lower the trust-region radius." + +### 9. Cap visible lists to 5 items +Group related items, rank the most relevant first, show at most five per group. Retain the +rest internally; surface them when asked or when they become the next thing to address. +This shapes **presentation only** — it must never limit analysis, tool results, search, +candidate generation, or retained information. + +### 10. No preamble, no recap, no closing pleasantries +Forbidden openers: "Great question," "Let me…," "I'll…," "Sure!," "Looking at your…," "To answer your question…" +Forbidden recaps: "I've now done X, Y, and Z, which means…" +Forbidden closers: "Let me know if you need anything else," "Hope this helps," "Feel free to ask." +Start with the answer. End when the answer is done. + +## When to break the rules + +Override the defaults when: + +1. **The reader asks to "explain" or "walk me through."** Explain fully — still no preamble/closer, but the body runs as long as the topic needs. Add headers so they can skim back. +2. **A destructive or irreversible action is ahead** (`rm -rf`, force push, a paid hardware/QPU submit, a schema migration, promotion to a shared vault/catalog). Confirm before acting — safety and the human gate win over brevity. +3. **Debug spiral.** If the last three turns are "still broken," stop iterating on code: name the assumption that might be wrong and ask one diagnostic question. +4. **Real ambiguity.** One short clarifying question beats guessing and rewriting. +5. **A rule fights the task.** When a rule would delete the answer itself, the task wins, the shape stays. "What are my options?" gets 2–4 ranked options with one-line trade-offs, recommendation first — the options are the answer. +6. **A rule fights the harness or the persona.** Amico's own contract outranks this skill: keep interview questions one-per-turn, announce tool calls when the harness requires it, honor every gate and verification step, and never let brevity suppress a caveat, an "untrusted until verified" flag, or a required confirmation. A `free`-tier fidelity is still untrusted and you still say so. + +## Pre-send check + +Before sending, delete: +1. The first sentence if it announces what you are about to do. +2. The last sentence if it asks "anything else?" or recaps what just happened. +3. Any "by the way" sidebar. +4. Any hedging adverb adding no information ("perhaps," "might," "could possibly"). Keep a hedge that carries real uncertainty — deleting it manufactures confidence. +5. Any idiom or figurative phrase ("circle back," "on the same page"). Replace with the literal action. + +Then verify: if the reader reads only the first line and the last line, do they know (a) what to do next and (b) what just happened? If yes, send. From 5d7af5f28000ed97f2396f2b1946018e267096e4 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 15:27:01 -0400 Subject: [PATCH 07/80] feat(app): concise-mode toggle in the composer bottom row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A little icon-only toggle (the 'collapse' glyph) beside the report-bug button that turns the concise skill on/off by sending its own toggle phrases ('concise mode' / 'normal mode'). ON state uses the accent selected-state tokens (--accent-fill-soft + --accent-edge); aria-pressed + a state-dependent label carry the meaning so color isn't the only signal. Both themes, focus ring and tokens inherited from IconButtonV2. Lightweight by design — drives the skill, not a persisted engine setting. Part of the concise-mode feature (with the skill in this branch). --- packages/app-bundle/manifest.json | 12 ++++--- .../src/components/concise-mode-button.css | 8 +++++ .../src/components/concise-mode-button.tsx | 32 +++++++++++++++++ .../app/src/components/prompt-input-v2.tsx | 35 +++++++++++++++++-- 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.css create mode 100644 packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 8e65c7f34..5c41cfe4e 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -98,6 +98,8 @@ "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": "11c17d7fdb29d16530e188b13ee8af728f37d9adfbb518084106ce50cc0c527a", + "packages/app/src/components/concise-mode-button.css": "396dc1fa856e882db45e6ed70ae328ad02ce42faa7215c135fd4627a3e254b13", + "packages/app/src/components/concise-mode-button.tsx": "c4b5c7be4fb225aeef22767fda6709512dcbe13e90e531e0e476e2487f3a54fa", "packages/app/src/components/connection-banner.tsx": "c793966e1f9d6f307af697032b235c2ff65afaa04f6cc232e40ce45e5745ded4", "packages/app/src/components/dialog-command-palette-v2.css": "85984957adaf15efcc0828310f3a0b438cedaaba776a9fec2eddb3ad83487fa2", "packages/app/src/components/dialog-command-palette-v2.tsx": "e12da3e5e9e503089dd9139b20e04f6b566cf74fa445f23a2e193f5883f7c5f3", @@ -115,7 +117,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": "5ab46e41bda4fa11e99f06c37df76c1fd3cce061c6ba7a9f85bee369a965b98a", + "packages/app/src/components/prompt-input-v2.tsx": "168652d1005de6cb7a8f733002e222bab07ebde006454597b1bda7166fb918dc", "packages/app/src/components/prompt-input.tsx": "97a9bc55b46cf1f2d7c3e38110e736f7fa9db7f89121d3c013b9650a60d382d4", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", "packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd", @@ -873,10 +875,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-24T04:46:38.771Z", + "extracted_at": "2026-09-28T19:26:06.623Z", "per_package": { "packages/app": { - "A": 186, + "A": 188, "M": 157, "D": 0 }, @@ -922,7 +924,7 @@ } }, "counts": { - "overlay_total": 851, + "overlay_total": 853, "deletions": 7, "server_coupled": 0 }, @@ -1017,6 +1019,8 @@ "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/concise-mode-button.css": "A", + "packages/app/src/components/concise-mode-button.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", diff --git a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.css b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.css new file mode 100644 index 000000000..c0af5ffd5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.css @@ -0,0 +1,8 @@ +/* amicode: concise-mode toggle ON state. Uses the accent selected-state tokens + — soft fill + a defining edge — the same pattern as the other amicode + selected surfaces (defaults capsule, solver toggle, connections row). Yellow + appears only as a soft fill + edge here, never as a foreground on light. */ +[data-component="icon-button-v2"].concise-mode-button--on { + background: var(--accent-fill-soft); + box-shadow: inset 0 0 0 var(--border-width, 1px) var(--accent-edge); +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.tsx b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.tsx new file mode 100644 index 000000000..ff161b851 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.tsx @@ -0,0 +1,32 @@ +// amicode: concise-mode toggle for the v2 composer's bottom control row. +// A little icon-only toggle that turns Amico's `concise` output-shaping skill +// on/off by sending the skill's own toggle phrases ("concise mode" / +// "normal mode"). Icon-only: aria-pressed + a state-dependent label carry the +// meaning — color is never the only signal (design-system a11y rule). The ON +// state uses the accent selected-state tokens (--accent-fill-soft + +// --accent-edge), the same warmth/edge pattern as the other amicode selected +// surfaces — never a yellow foreground on light. +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import "./concise-mode-button.css" + +export function ConciseModeButton(props: { active: boolean; disabled?: boolean; onToggle: () => void }) { + const label = () => (props.active ? "Concise mode: on" : "Concise mode: off") + return ( + + } + aria-label={label()} + aria-pressed={props.active} + disabled={props.disabled} + onClick={() => props.onToggle()} + /> + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx index ad9cc7ecb..9cf091a53 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx @@ -6,10 +6,11 @@ import { Icon } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { ReportBugButton } from "@/components/report-bug-button" +import { ConciseModeButton } from "@/components/concise-mode-button" import type { PromptInputProps } from "@/components/prompt-input/contracts" import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" @@ -48,6 +49,10 @@ export type PromptInputV2ComposerProps = { export type PromptInputV2ControllerProps = Omit export type PromptInputV2ComposerController = PromptInputV2Interaction & { readonly model: PromptInputProps["controls"]["model"] + // amicode: the concise-mode toggle state + action, surfaced to the composer's + // trailing controls. `on` is a reactive accessor; `toggle` flips it and sends + // the concise skill's own toggle phrase. + readonly concise: { readonly on: () => boolean; readonly toggle: () => void } } export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { @@ -67,7 +72,15 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { // amicode/opencode#116: report-a-bug, right-anchored immediately left // of send. The boot-param gate lives HERE (not inside the button) so a // gated-off button passes `undefined` and the row's layout never shifts. - trailingControl={bugReportEnabled() ? : undefined} + trailingControl={ + <> + props.controller.concise.toggle()} + /> + {bugReportEnabled() ? : undefined} + + } modelControl={ { + if (working()) return + const next = !conciseOn() + setConciseOn(next) + const phrase = next ? "concise mode" : "normal mode" + const parts = [{ type: "text" as const, content: phrase, start: 0, end: phrase.length }] + prompt.set(parts, promptLength(parts)) + void submission.handleSubmit(new Event("submit")) + } + const concise = { on: conciseOn, toggle: toggleConcise } + const referenceDescription = (reference: ReferenceInfo) => reference.source.type === "git" ? reference.source.repository : reference.source.path const references = createMemo(() => @@ -437,6 +467,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): }, }) Object.defineProperty(controller, "model", { get: () => props.controls.model }) + Object.defineProperty(controller, "concise", { get: () => concise }) // Framed webview: the window-level fallback (global-clipboard.ts) is the // sole ⌘V owner. When the clipboard carries no text it offers the media to From 59cbefb5f02988142a944d2f8940710f99df77c8 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 15:45:44 -0400 Subject: [PATCH 08/80] feat(app): concise mode as a composer switch, applied on send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ambiguous collapse-icon button with a real switch (Concise). It no longer sends its own message: flipping it sets a persistent per-session flag, and while on, a concise directive is appended to the outgoing prose message at submit time (normal turns only — shell/slash unaffected), so the response comes back concise. User text and history stay unmodified. - concise-mode-toggle.tsx: switch (checked/onChange) + label, replaces the icon button - prompt-input-v2: controller exposes concise {on,set}; passed into createPromptSubmit - submit.ts: PromptSubmitInput.concise; conciseDraftPrompt appends directive to the sent draft App typecheck clean; materialize manifest refreshed. --- packages/app-bundle/manifest.json | 16 ++++---- .../src/components/concise-mode-button.css | 8 ---- .../src/components/concise-mode-button.tsx | 32 --------------- .../src/components/concise-mode-toggle.tsx | 23 +++++++++++ .../app/src/components/prompt-input-v2.tsx | 39 +++++++------------ .../app/src/components/prompt-input/submit.ts | 17 +++++++- 6 files changed, 61 insertions(+), 74 deletions(-) delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.css delete mode 100644 packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/components/concise-mode-toggle.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 5c41cfe4e..633cfe7e1 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -98,8 +98,7 @@ "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": "11c17d7fdb29d16530e188b13ee8af728f37d9adfbb518084106ce50cc0c527a", - "packages/app/src/components/concise-mode-button.css": "396dc1fa856e882db45e6ed70ae328ad02ce42faa7215c135fd4627a3e254b13", - "packages/app/src/components/concise-mode-button.tsx": "c4b5c7be4fb225aeef22767fda6709512dcbe13e90e531e0e476e2487f3a54fa", + "packages/app/src/components/concise-mode-toggle.tsx": "55ac18a80e54c8534e8f76944123b1416d0ace815c4ed34100ab71c620981b7d", "packages/app/src/components/connection-banner.tsx": "c793966e1f9d6f307af697032b235c2ff65afaa04f6cc232e40ce45e5745ded4", "packages/app/src/components/dialog-command-palette-v2.css": "85984957adaf15efcc0828310f3a0b438cedaaba776a9fec2eddb3ad83487fa2", "packages/app/src/components/dialog-command-palette-v2.tsx": "e12da3e5e9e503089dd9139b20e04f6b566cf74fa445f23a2e193f5883f7c5f3", @@ -117,7 +116,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": "168652d1005de6cb7a8f733002e222bab07ebde006454597b1bda7166fb918dc", + "packages/app/src/components/prompt-input-v2.tsx": "a982b27659c126e3148ffd3f9fca97da5a349aafde164871e4398b56aa12be6b", "packages/app/src/components/prompt-input.tsx": "97a9bc55b46cf1f2d7c3e38110e736f7fa9db7f89121d3c013b9650a60d382d4", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", "packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd", @@ -624,7 +623,7 @@ "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-stream-gap.test.ts": "68a94b03e75625531b2f2ad161ef9d0b93365f9edae154a797f82031b5bfc062", - "packages/app/src/components/prompt-input/submit.ts": "6f14368b0d481bdf10a2711e08da41010edd9f67ca8af90b930c096a56778b89", + "packages/app/src/components/prompt-input/submit.ts": "7f9c88406d600e1d5de80ce9fab1279ce32fc294da2371d0bf0bc11cc4257aba", "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", @@ -875,10 +874,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-28T19:26:06.623Z", + "extracted_at": "2026-09-28T19:44:50.621Z", "per_package": { "packages/app": { - "A": 188, + "A": 187, "M": 157, "D": 0 }, @@ -924,7 +923,7 @@ } }, "counts": { - "overlay_total": 853, + "overlay_total": 852, "deletions": 7, "server_coupled": 0 }, @@ -1019,8 +1018,7 @@ "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/concise-mode-button.css": "A", - "packages/app/src/components/concise-mode-button.tsx": "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", diff --git a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.css b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.css deleted file mode 100644 index c0af5ffd5..000000000 --- a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.css +++ /dev/null @@ -1,8 +0,0 @@ -/* amicode: concise-mode toggle ON state. Uses the accent selected-state tokens - — soft fill + a defining edge — the same pattern as the other amicode - selected surfaces (defaults capsule, solver toggle, connections row). Yellow - appears only as a soft fill + edge here, never as a foreground on light. */ -[data-component="icon-button-v2"].concise-mode-button--on { - background: var(--accent-fill-soft); - box-shadow: inset 0 0 0 var(--border-width, 1px) var(--accent-edge); -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.tsx b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.tsx deleted file mode 100644 index ff161b851..000000000 --- a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-button.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// amicode: concise-mode toggle for the v2 composer's bottom control row. -// A little icon-only toggle that turns Amico's `concise` output-shaping skill -// on/off by sending the skill's own toggle phrases ("concise mode" / -// "normal mode"). Icon-only: aria-pressed + a state-dependent label carry the -// meaning — color is never the only signal (design-system a11y rule). The ON -// state uses the accent selected-state tokens (--accent-fill-soft + -// --accent-edge), the same warmth/edge pattern as the other amicode selected -// surfaces — never a yellow foreground on light. -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import "./concise-mode-button.css" - -export function ConciseModeButton(props: { active: boolean; disabled?: boolean; onToggle: () => void }) { - const label = () => (props.active ? "Concise mode: on" : "Concise mode: off") - return ( - - } - aria-label={label()} - aria-pressed={props.active} - disabled={props.disabled} - onClick={() => props.onToggle()} - /> - - ) -} diff --git a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-toggle.tsx b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-toggle.tsx new file mode 100644 index 000000000..0247648fd --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-toggle.tsx @@ -0,0 +1,23 @@ +// amicode: concise-mode toggle for the v2 composer's bottom control row. +// A real switch (not an icon) — flipping it does NOT send a message; it sets a +// persistent per-session flag that, while on, prepends a concise directive to +// the NEXT message you send, so the response comes back concise. Turning it off +// stops that. The switch's own accent/checked styling (switch-v2.css) carries +// the on state; the label + tooltip carry the meaning. +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" + +export function ConciseModeToggle(props: { active: boolean; disabled?: boolean; onToggle: (next: boolean) => void }) { + return ( + + props.onToggle(next)} + > + Concise + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx index 9cf091a53..cac14c73d 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx @@ -10,7 +10,7 @@ import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "sol import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { ReportBugButton } from "@/components/report-bug-button" -import { ConciseModeButton } from "@/components/concise-mode-button" +import { ConciseModeToggle } from "@/components/concise-mode-toggle" import type { PromptInputProps } from "@/components/prompt-input/contracts" import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" @@ -49,10 +49,10 @@ export type PromptInputV2ComposerProps = { export type PromptInputV2ControllerProps = Omit export type PromptInputV2ComposerController = PromptInputV2Interaction & { readonly model: PromptInputProps["controls"]["model"] - // amicode: the concise-mode toggle state + action, surfaced to the composer's - // trailing controls. `on` is a reactive accessor; `toggle` flips it and sends - // the concise skill's own toggle phrase. - readonly concise: { readonly on: () => boolean; readonly toggle: () => void } + // amicode: the concise-mode toggle state + setter, surfaced to the composer's + // trailing controls. `on` is a reactive accessor; `set` flips a persistent + // per-session flag — it sends nothing. The flag is read at submit time. + readonly concise: { readonly on: () => boolean; readonly set: (next: boolean) => void } } export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { @@ -74,9 +74,9 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { // gated-off button passes `undefined` and the row's layout never shifts. trailingControl={ <> - props.controller.concise.toggle()} + onToggle={(next) => props.controller.concise.set(next)} /> {bugReportEnabled() ? : undefined} @@ -229,6 +229,13 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) return permission.isAutoAccepting(id, sdk().directory) }) + // amicode: concise-mode flag. A persistent per-session switch — flipping it + // sends nothing; it is read at submit time (createPromptSubmit's `concise`) to + // append a concise directive to the outgoing prose message. Lightweight by + // design: the response-presentation engine transport was parked in favor of + // the concise skill, and this drives that skill per sent message. + const [conciseOn, setConciseOn] = createSignal(false) + const concise = { on: conciseOn, set: setConciseOn } const submission = createPromptSubmit({ prompt, info, @@ -254,25 +261,9 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): onSubmit: props.onSubmit, streamGap, model: props.controls.model.selection, + concise: conciseOn, }) - // amicode: concise-mode toggle. Lightweight by design — it drives Amico's - // `concise` output-shaping skill by sending the skill's own toggle phrases, - // rather than a persisted engine setting (the response-presentation transport - // epic was deliberately parked in favor of the skill). Between-messages action: - // it no-ops while a turn is working, and sends the phrase as its own turn. - const [conciseOn, setConciseOn] = createSignal(false) - const toggleConcise = () => { - if (working()) return - const next = !conciseOn() - setConciseOn(next) - const phrase = next ? "concise mode" : "normal mode" - const parts = [{ type: "text" as const, content: phrase, start: 0, end: phrase.length }] - prompt.set(parts, promptLength(parts)) - void submission.handleSubmit(new Event("submit")) - } - const concise = { on: conciseOn, toggle: toggleConcise } - const referenceDescription = (reference: ReferenceInfo) => reference.source.type === "git" ? reference.source.repository : reference.source.path const references = createMemo(() => diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts index a6d6843f6..3f2e3a660 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts @@ -56,6 +56,15 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") +// amicode: when the composer's concise switch is on, append a concise directive +// to the outgoing prose message so the response comes back concise. Rides with +// the message (a leading blank line separates it from the user's text); the +// user's text and history stay unmodified — only the sent draft carries it. +const CONCISE_DIRECTIVE = + "\n\n[Concise mode: respond concisely — lead with the answer or next action, no preamble or closing pleasantries.]" +const conciseDraftPrompt = (prompt: Prompt, on: boolean): Prompt => + on ? [...prompt, { type: "text", content: CONCISE_DIRECTIVE, start: 0, end: 0 }] : prompt + export async function sendFollowupDraft(input: FollowupSendInput) { const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) @@ -234,6 +243,12 @@ type PromptSubmitInput = { * notice instead of optimistically posting into a dead tunnel. */ streamGap?: Accessor model?: ModelSelection + /** amicode: when true at send time, a concise directive is appended to the + * outgoing message (normal prose turns only) so the response comes back + * concise — the composer's concise switch drives this. Not a hidden channel: + * the directive rides with the sent message (the response-presentation + * engine transport that would make it invisible was parked). */ + concise?: Accessor } export function createPromptSubmit(input: PromptSubmitInput) { @@ -468,7 +483,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const draft: FollowupDraft = { sessionID: session.id, sessionDirectory, - prompt: currentPrompt, + prompt: conciseDraftPrompt(currentPrompt, input.concise?.() ?? false), context, agent, model, From a2fdaa3d1b7af721c288abee4a08be98abe1f794 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 15:53:20 -0400 Subject: [PATCH 09/80] fix(app): concise directive rides as invisible per-turn system, not visible text The concise switch appended a bracketed directive to the sent message text, which showed in the user's bubble. Move it to the message's per-turn `system` instruction instead: it reaches the model for that turn but is not rendered as a visible part. Threads system through FollowupDraft, the send call, the optimistic message, and the server-compat prompt bridge (forwarded to promptAsync's system). User text stays clean. App typecheck clean. --- packages/app-bundle/manifest.json | 6 +++--- .../app/src/components/prompt-input/submit.ts | 19 +++++++++++-------- .../packages/app/src/utils/server-compat.ts | 5 +++++ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 633cfe7e1..e6ee21702 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -243,7 +243,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", @@ -623,7 +623,7 @@ "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-stream-gap.test.ts": "68a94b03e75625531b2f2ad161ef9d0b93365f9edae154a797f82031b5bfc062", - "packages/app/src/components/prompt-input/submit.ts": "7f9c88406d600e1d5de80ce9fab1279ce32fc294da2371d0bf0bc11cc4257aba", + "packages/app/src/components/prompt-input/submit.ts": "38621336b3c22a1e67cada6693a8e029fad963825ed4e2a6fec1ee39058f2278", "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", @@ -874,7 +874,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-28T19:44:50.621Z", + "extracted_at": "2026-09-28T19:53:01.096Z", "per_package": { "packages/app": { "A": 187, diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts index 3f2e3a660..a0ed8bf50 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts @@ -40,6 +40,9 @@ export type FollowupDraft = { agent: string model: { providerID: string; modelID: string } variant?: string + // amicode: per-turn system instruction (e.g. the concise directive). Reaches + // the model for this turn but is NOT rendered as a visible message part. + system?: string } type FollowupSendInput = { @@ -56,14 +59,11 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") -// amicode: when the composer's concise switch is on, append a concise directive -// to the outgoing prose message so the response comes back concise. Rides with -// the message (a leading blank line separates it from the user's text); the -// user's text and history stay unmodified — only the sent draft carries it. +// amicode: the concise directive. When the composer's concise switch is on it +// rides as the message's per-turn `system` instruction (invisible — reaches the +// model, not rendered as a user part), never appended to the visible text. const CONCISE_DIRECTIVE = - "\n\n[Concise mode: respond concisely — lead with the answer or next action, no preamble or closing pleasantries.]" -const conciseDraftPrompt = (prompt: Prompt, on: boolean): Prompt => - on ? [...prompt, { type: "text", content: CONCISE_DIRECTIVE, start: 0, end: 0 }] : prompt + "Concise mode is on for this message. Respond concisely: lead with the answer or next action, no preamble or closing pleasantries, and keep it tight — without dropping required caveats, questions, or safety confirmations." export async function sendFollowupDraft(input: FollowupSendInput) { const text = draftText(input.draft.prompt) @@ -144,6 +144,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { time: { created: Date.now() }, agent: input.draft.agent, model: { ...input.draft.model, variant: input.draft.variant }, + system: input.draft.system, } const add = () => @@ -181,6 +182,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { agent: input.draft.agent, model: input.draft.model, variant: input.draft.variant, + system: input.draft.system, legacyParts: requestParts, text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), files: requestParts.flatMap((part) => { @@ -483,11 +485,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { const draft: FollowupDraft = { sessionID: session.id, sessionDirectory, - prompt: conciseDraftPrompt(currentPrompt, input.concise?.() ?? false), + prompt: currentPrompt, context, agent, model, variant, + system: (input.concise?.() ?? false) ? CONCISE_DIRECTIVE : undefined, } const clearInput = () => { diff --git a/packages/app-bundle/overlay/packages/app/src/utils/server-compat.ts b/packages/app-bundle/overlay/packages/app/src/utils/server-compat.ts index b928d9715..23c4b9aaa 100644 --- a/packages/app-bundle/overlay/packages/app/src/utils/server-compat.ts +++ b/packages/app-bundle/overlay/packages/app/src/utils/server-compat.ts @@ -44,6 +44,10 @@ type LegacyPrompt = { agent?: string model?: { providerID: string; modelID: string } variant?: string + // amicode: per-turn system instruction (e.g. the composer's concise + // directive) — forwarded to promptAsync's `system`, seen by the model for + // this turn but not rendered as a visible message part. + system?: string legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[] } type LegacyLocation = { directory?: string } @@ -218,6 +222,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { agent: value.agent, model: value.model, variant: value.variant, + system: value.system, parts: value.legacyParts ?? [ { type: "text", text: value.text }, ...(value.files ?? []).map((file) => ({ From e4f45c7cb4695f914043a82f6aa07c54b5305fc4 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 16:10:14 -0400 Subject: [PATCH 10/80] =?UTF-8?q?refactor(app):=20drop=20concise-mode=20to?= =?UTF-8?q?oltip=20=E2=80=94=20label=20is=20self-explanatory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Concise' switch label needs no hover description; remove the TooltipV2 wrapper. --- packages/app-bundle/manifest.json | 4 +-- .../src/components/concise-mode-toggle.tsx | 28 +++++++++---------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index e6ee21702..769606f05 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -98,7 +98,7 @@ "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": "11c17d7fdb29d16530e188b13ee8af728f37d9adfbb518084106ce50cc0c527a", - "packages/app/src/components/concise-mode-toggle.tsx": "55ac18a80e54c8534e8f76944123b1416d0ace815c4ed34100ab71c620981b7d", + "packages/app/src/components/concise-mode-toggle.tsx": "3eff0c5557143500a27d828a08d1efe09a8c99aac9b6e2f9950bfe70c76162a1", "packages/app/src/components/connection-banner.tsx": "c793966e1f9d6f307af697032b235c2ff65afaa04f6cc232e40ce45e5745ded4", "packages/app/src/components/dialog-command-palette-v2.css": "85984957adaf15efcc0828310f3a0b438cedaaba776a9fec2eddb3ad83487fa2", "packages/app/src/components/dialog-command-palette-v2.tsx": "e12da3e5e9e503089dd9139b20e04f6b566cf74fa445f23a2e193f5883f7c5f3", @@ -874,7 +874,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-28T19:53:01.096Z", + "extracted_at": "2026-09-28T20:09:50.705Z", "per_package": { "packages/app": { "A": 187, diff --git a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-toggle.tsx b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-toggle.tsx index 0247648fd..ed604f0ea 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/concise-mode-toggle.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/concise-mode-toggle.tsx @@ -1,23 +1,21 @@ // amicode: concise-mode toggle for the v2 composer's bottom control row. // A real switch (not an icon) — flipping it does NOT send a message; it sets a -// persistent per-session flag that, while on, prepends a concise directive to -// the NEXT message you send, so the response comes back concise. Turning it off -// stops that. The switch's own accent/checked styling (switch-v2.css) carries -// the on state; the label + tooltip carry the meaning. +// persistent per-session flag that, while on, attaches a concise directive to +// the NEXT message you send (as the invisible per-turn system instruction), so +// the response comes back concise. Turning it off stops that. The switch's own +// accent/checked styling (switch-v2.css) carries the on state; the "Concise" +// label is self-explanatory, so there is no tooltip. import { Switch } from "@opencode-ai/ui/v2/switch-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" export function ConciseModeToggle(props: { active: boolean; disabled?: boolean; onToggle: (next: boolean) => void }) { return ( - - props.onToggle(next)} - > - Concise - - + props.onToggle(next)} + > + Concise + ) } From 7208d918e2ebc978383fc1225acc8ea6fbee05be Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 16:42:52 -0400 Subject: [PATCH 11/80] fix(app): persist preview open-paths across editor reload (#1640) Preview tabs were lost on webview reload: the active file lived in the non-persisted ephemeral store and the full open-tab set only in SessionPreviewTab's component-local workspace signal. Move both into the persisted per-session SessionView store (previewActive + previewPaths), reusing the serverGlobal machinery that already survives reload. - layout.tsx: SessionView gains previewActive/previewPaths; previewFile getter/setter repointed from ephemeral -> persisted store; drop the ephemeral sessionPreviewFile. - session-preview-tab.tsx: restore the workspace from the persisted list on mount (before the previewFile re-focus; idempotent), mirror open/close back up (gated on restore to avoid clobbering pre-hydration). - session-side-panel.tsx: wire openPaths/onOpenedPathsChange at both sites. - session-preview-tree.test.ts: open-paths round-trip invariants. Split-pane layout stays out of scope (#981). Implements the #1398 wiring in the persisted store so it covers reload as well as remount. --- .../session/session-preview-tab.tsx | 33 ++++++++++++++ .../session/session-preview-tree.test.ts | 37 +++++++++++++++ .../packages/app/src/context/layout.tsx | 45 ++++++++++++++++--- .../src/pages/session/session-side-panel.tsx | 11 +++-- 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx index 52b627d99..5c579f32e 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx @@ -96,6 +96,11 @@ export function SessionPreviewTab(props: { * a preview tab within the pane). Composed with isSelected to form the * full `active` signal so hide/show fires on side-panel tab switches too. */ panelVisible?: () => boolean + /** Persisted open-paths list (from the session view). Restores every open + * preview tab across remounts and editor reloads; when absent the tab keeps + * its old purely-local, non-persisted behavior. #1398 + reload persistence. */ + openPaths?: Accessor + onOpenedPathsChange?: (paths: string[]) => void }) { const platform = usePlatform() const [dirtyPaths, setDirtyPaths] = createStore>({}) @@ -128,6 +133,24 @@ export function SessionPreviewTab(props: { setCapacityMessage(null) } + // Restore the open preview tabs from the persisted list once it hydrates, as + // long as the user hasn't already opened something this mount. Runs *before* + // the previewFile effect so the full set is rebuilt before the active file is + // re-focused (openPreviewPath is idempotent on an already-open path). The + // split-pane layout is intentionally not restored — only the flat path list. + let restoredPreview = false + createEffect(() => { + if (restoredPreview) return + const persisted = props.openPaths?.() ?? [] + if (openedPaths().length > 0) { + restoredPreview = true + return + } + if (persisted.length === 0) return + setWorkspace(createPreviewWorkspace(persisted)) + restoredPreview = true + }) + createEffect( on( () => props.previewFile(), @@ -137,6 +160,16 @@ export function SessionPreviewTab(props: { ), ) + // Mirror the live open-paths set up to the persisted session view so a reload + // restores it. Gated on restore so we never clobber the persisted list with an + // empty set before hydration completes. + createEffect( + on(openedPaths, (paths) => { + if (!restoredPreview) return + props.onOpenedPathsChange?.(paths) + }), + ) + onMount(() => { const handlePreviewFile = (event: MessageEvent) => { const data = event.data as { source?: string; kind?: string; path?: string } | undefined diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts index cbb3542f9..2948a641a 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts @@ -6,6 +6,7 @@ import { previewLeafByID, previewLeaves, previewMinimumExtent, + removePreviewPath, setPreviewLeafZoom, } from "./session-preview-tree" @@ -89,3 +90,39 @@ describe("Preview workspace pane tree", () => { expect(previewLeafByID(transferred.tree, "root")?.zoom).toBe(90) }) }) + +// The flat list of open paths is what the session view persists and what the +// SessionPreviewTab restores from on reload/remount. These tests pin the +// round-trip invariant that restore depends on (split-pane layout is +// intentionally NOT persisted — only this flat list + the active tab). +describe("Preview open-paths persistence round-trip", () => { + const openedPaths = (workspace: ReturnType) => + previewLeaves(workspace.tree).flatMap((leaf) => leaf.tabs) + + test("restores the open-paths list in order, activating the last", () => { + const paths = ["file://paper3.tex", "file://paper3.pdf"] + const restored = createPreviewWorkspace(paths) + expect(openedPaths(restored)).toEqual(paths) + const root = previewLeafByID(restored.tree, restored.focusedLeafID) + expect(root?.selectedPath).toBe("file://paper3.pdf") + }) + + test("an empty persisted list restores to an empty workspace", () => { + const restored = createPreviewWorkspace([]) + expect(openedPaths(restored)).toEqual([]) + expect(previewLeafByID(restored.tree, restored.focusedLeafID)?.selectedPath).toBeNull() + }) + + test("re-opening the persisted active path after restore is idempotent (no duplicate)", () => { + const restored = createPreviewWorkspace(["file://paper3.tex", "file://paper3.pdf"]) + const refocused = openPreviewPath(restored, "file://paper3.tex") + expect(openedPaths(refocused)).toEqual(["file://paper3.tex", "file://paper3.pdf"]) + expect(previewLeafByID(refocused.tree, refocused.focusedLeafID)?.selectedPath).toBe("file://paper3.tex") + }) + + test("closing a tab shrinks the persisted list", () => { + const restored = createPreviewWorkspace(["file://paper3.tex", "file://paper3.pdf"]) + const afterClose = removePreviewPath(restored, "file://paper3.pdf") + expect(openedPaths(afterClose)).toEqual(["file://paper3.tex"]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/layout.tsx b/packages/app-bundle/overlay/packages/app/src/context/layout.tsx index 09ab26227..a95a2cc9c 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/layout.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/layout.tsx @@ -86,6 +86,13 @@ type SessionView = { pendingMessage?: string pendingMessageAt?: number todoCollapsed?: boolean + /** Preview companion — the active file shown in the Preview tab. Persisted + * (was ephemeral) so it survives session re-instantiation AND editor reloads. */ + previewActive?: string | null + /** Preview companion — the ordered list of open Preview file paths, so a + * reload restores every open preview tab, not just the active one. Split-pane + * layout is deliberately not persisted here (see #981). */ + previewPaths?: string[] } type TabHandoff = { @@ -341,8 +348,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const [ephemeral, setEphemeral] = createStore({ reviewPanelSource: "other" as ReviewPanelSource, sessionTabPreview: {} as Record, - /** Per-session preview file path (companion viewer). Null = empty state. */ - sessionPreviewFile: {} as Record, }) const MAX_SESSION_KEYS = 50 @@ -923,11 +928,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } }, }, - /** Preview companion — the file to show in the Preview tab. */ + /** Preview companion — the active file shown in the Preview tab. + * Persisted in the session view so it survives session + * re-instantiation AND editor reloads (#1398 + reload persistence). */ previewFile: { - get: createMemo(() => ephemeral.sessionPreviewFile[key()] ?? null), + get: createMemo(() => s().previewActive ?? null), set(path: string | null) { - setEphemeral("sessionPreviewFile", key(), path) + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, previewActive: path }) + prune(session) + return + } + if (current.previewActive === path) return + setStore("sessionView", session, "previewActive", path) + prune(session) + }, + }, + /** Preview companion — the ordered list of open Preview file paths. + * Persisted alongside previewActive so a reload restores every open + * preview tab, not just the active one. */ + previewPaths: { + get: createMemo(() => s().previewPaths ?? []), + set(paths: string[]) { + const session = key() + const next = Array.from(new Set(paths)) + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, previewPaths: next }) + prune(session) + return + } + if (same(current.previewPaths, next)) return + setStore("sessionView", session, "previewPaths", next) + prune(session) }, }, terminal: { diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx index 158278474..db136f4a4 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx @@ -379,9 +379,12 @@ export function SessionSidePanel(props: { // ── Preview companion state ────────────────────────────────────────────── // The file to show in the Preview tab. Null = empty state ("Select a file - // from the sidebar"). Managed in the layout context's ephemeral store so - // the bridge message handler can set it from outside the side panel. + // from the sidebar"). Managed in the layout context's persisted session-view + // store (previewActive + previewPaths) so the bridge message handler can set + // it from outside the side panel AND it survives reloads. const previewFile = createMemo(() => view().previewFile.get()) + const previewPaths = createMemo(() => view().previewPaths.get()) + const setPreviewPaths = (paths: string[]) => view().previewPaths.set(paths) createEffect( on(previewFile, (path) => { @@ -920,7 +923,7 @@ export function SessionSidePanel(props: { inert={activeTab() !== SESSION_PREVIEW_TAB} >
- activeTab() === SESSION_PREVIEW_TAB} /> + activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} />
@@ -1250,7 +1253,7 @@ export function SessionSidePanel(props: { inert={activeTab() !== SESSION_PREVIEW_TAB} >
- activeTab() === SESSION_PREVIEW_TAB} /> + activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} />
From 2a6f71140ddaa8a12175b56b5862c13392991f2a Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 17:04:51 -0400 Subject: [PATCH 12/80] fix(app): gate preview restore on store hydration (fixes only-1-of-N) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted layout store hydrates asynchronously. The first restore ran before hydration (openPaths() still empty), the previewFile effect opened the active file, restore then bailed on openedPaths().length>0, and the up-sync wrote the shrunk [active] list back — clobbering the persisted list down to one tab. Only the active file came back on reload. Gate reconciliation on the layout store's `ready` signal (threaded in as `hydrated`): hold both restore and up-sync until hydration completes, then MERGE the persisted list with anything already open (union, persisted order) and keep the active tab focused. Extract the merge into a pure `reconcilePreviewPaths` helper and test it — the [a,b,c] vs open [c] -> [a,b,c] case is the regression. --- packages/app-bundle/manifest.json | 10 ++--- .../session/session-preview-tab.tsx | 45 ++++++++++++------- .../session/session-preview-tree.test.ts | 23 ++++++++++ .../session/session-preview-tree.ts | 9 ++++ .../src/pages/session/session-side-panel.tsx | 4 +- 5 files changed, 68 insertions(+), 23 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 8e65c7f34..56dd4a541 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -157,7 +157,7 @@ "packages/app/src/context/layout-side-panel-tabs.ts": "e8426fadbdf9e7c49bf457e00ec92793475b44bcc11548e8b5ef9a9fa461be76", "packages/app/src/context/layout-tabs.test.ts": "4d9fdbe963306f164b2f48eac3b6a28c5a703c1758bf14b2cc4796720d8fa72c", "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", @@ -636,10 +636,10 @@ "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", "packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe", + "packages/app/src/components/session/session-preview-tab.tsx": "57241785d50c7e1b4f36808fce4f50145d372f3b60626e4fec93d9741b2a8b9d", "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.test.ts": "5ef5e18ced51e46591acef2500b97947f799c2485fcc648b9d49e95dee440fac", "packages/app/src/components/session/session-preview-tree.ts": "13930fcd8a8ae9df4c37ba9ea306e162fd08f0e429249ddea21d937675d3ad7e", "packages/app/src/components/session/session-sortable-tab.tsx": "a015277347fe679ac1afa1372202de49fc98a0930ac10682c8897f70a0300909", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", @@ -694,7 +694,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": "b295340d9a39ba37c9e025d6360669d2a879284c4e60df795b7902c5a4cef3fc", + "packages/app/src/pages/session/session-side-panel.tsx": "c00e8e89c9d1017de9379058c50d4e1ed0c21e0aa2a0f0582448035ca5005993", "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", @@ -873,7 +873,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-24T04:46:38.771Z", + "extracted_at": "2026-09-28T20:52:32.278Z", "per_package": { "packages/app": { "A": 186, diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx index 5c579f32e..a066cc9c7 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx @@ -26,6 +26,7 @@ import { previewLeaves, previewMinimumExtent, previewTabCount, + reconcilePreviewPaths, removePreviewPath, resizePreviewSplit, selectPreviewPath, @@ -101,6 +102,10 @@ export function SessionPreviewTab(props: { * its old purely-local, non-persisted behavior. #1398 + reload persistence. */ openPaths?: Accessor onOpenedPathsChange?: (paths: string[]) => void + /** True once the persisted layout store has hydrated. Reconciliation of the + * persisted open-paths list is held until this flips true (the store loads + * async); reconciling earlier clobbers the list to just the active file. */ + hydrated?: () => boolean }) { const platform = usePlatform() const [dirtyPaths, setDirtyPaths] = createStore>({}) @@ -133,22 +138,30 @@ export function SessionPreviewTab(props: { setCapacityMessage(null) } - // Restore the open preview tabs from the persisted list once it hydrates, as - // long as the user hasn't already opened something this mount. Runs *before* - // the previewFile effect so the full set is rebuilt before the active file is - // re-focused (openPreviewPath is idempotent on an already-open path). The - // split-pane layout is intentionally not restored — only the flat path list. - let restoredPreview = false + // Reconcile the persisted open-paths list into the workspace exactly once, + // AFTER the persisted store has hydrated (props.hydrated). Rebuild in the + // persisted order, unioning anything already open (e.g. the active file the + // previewFile effect opened first), then keep the active tab focused. + // + // Gating on hydration is essential: the persisted store loads asynchronously, + // so before `hydrated` flips true `props.openPaths()` still reads empty. + // Reconciling then (or letting the up-sync run then) would clobber the + // persisted list down to just the active file — the "only 1 of N restored" + // bug. We hold both restore and up-sync until hydration is done. + let reconciled = false createEffect(() => { - if (restoredPreview) return + if (reconciled) return + if (props.hydrated && !props.hydrated()) return const persisted = props.openPaths?.() ?? [] - if (openedPaths().length > 0) { - restoredPreview = true - return + const finalPaths = reconcilePreviewPaths(persisted, openedPaths()) + if (persisted.length > 0) { + let next = createPreviewWorkspace(finalPaths) + const active = props.previewFile() + if (active && finalPaths.includes(active)) next = openPreviewPath(next, active) + setWorkspace(next) } - if (persisted.length === 0) return - setWorkspace(createPreviewWorkspace(persisted)) - restoredPreview = true + reconciled = true + props.onOpenedPathsChange?.(finalPaths) }) createEffect( @@ -161,11 +174,11 @@ export function SessionPreviewTab(props: { ) // Mirror the live open-paths set up to the persisted session view so a reload - // restores it. Gated on restore so we never clobber the persisted list with an - // empty set before hydration completes. + // restores it. Held until reconciliation so a pre-hydration empty set never + // clobbers the persisted list. createEffect( on(openedPaths, (paths) => { - if (!restoredPreview) return + if (!reconciled) return props.onOpenedPathsChange?.(paths) }), ) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts index 2948a641a..7800d46eb 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts @@ -6,6 +6,7 @@ import { previewLeafByID, previewLeaves, previewMinimumExtent, + reconcilePreviewPaths, removePreviewPath, setPreviewLeafZoom, } from "./session-preview-tree" @@ -126,3 +127,25 @@ describe("Preview open-paths persistence round-trip", () => { expect(openedPaths(afterClose)).toEqual(["file://paper3.tex"]) }) }) + +// reconcilePreviewPaths is the merge that restore uses. The first case is the +// exact regression behind "only the active file came back on reload": the +// persisted list has all N, but the previewFile effect has only opened the +// active one when reconcile runs — the union must still be all N. +describe("reconcilePreviewPaths", () => { + test("restores the full persisted list when only the active file is open (the 1-of-N bug)", () => { + expect(reconcilePreviewPaths(["a", "b", "c"], ["c"])).toEqual(["a", "b", "c"]) + }) + + test("appends currently-open paths that were not persisted", () => { + expect(reconcilePreviewPaths(["a", "b"], ["b", "d"])).toEqual(["a", "b", "d"]) + }) + + test("keeps persisted order and de-duplicates when open is a subset", () => { + expect(reconcilePreviewPaths(["a", "b", "c"], ["a", "c"])).toEqual(["a", "b", "c"]) + }) + + test("an empty persisted list keeps whatever is currently open", () => { + expect(reconcilePreviewPaths([], ["x"])).toEqual(["x"]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts index d4bbee9bc..6e36eaae9 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts @@ -39,6 +39,15 @@ export const createPreviewWorkspace = (paths: readonly string[] = []): PreviewWo nextPaneID: 1, }) +/** The reconciled open-paths list when restoring persisted preview tabs: the + * persisted order first, then any paths already open that were not persisted + * (e.g. the active file the previewFile effect opened before hydration). An + * empty persisted list keeps whatever is currently open. This is what fixes + * "only the active file restores" — restoring [a,b,c] when only [c] is open + * must yield [a,b,c], not [c]. */ +export const reconcilePreviewPaths = (persisted: readonly string[], open: readonly string[]): string[] => + persisted.length > 0 ? [...persisted, ...open.filter((path) => !persisted.includes(path))] : [...open] + export const previewLeaves = (tree: PreviewPane): PreviewLeaf[] => { if (tree.kind === "leaf") return [tree] return [...previewLeaves(tree.first), ...previewLeaves(tree.second)] diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx index db136f4a4..3eb76cbc1 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx @@ -923,7 +923,7 @@ export function SessionSidePanel(props: { inert={activeTab() !== SESSION_PREVIEW_TAB} >
- activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} /> + activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} hydrated={layout.ready} />
@@ -1253,7 +1253,7 @@ export function SessionSidePanel(props: { inert={activeTab() !== SESSION_PREVIEW_TAB} >
- activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} /> + activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} hydrated={layout.ready} />
From 369fb9f8ffc99c4049959b38d31ef5d67e697cbe Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 28 Sep 2026 17:17:12 -0400 Subject: [PATCH 13/80] chore(app-bundle): refresh manifest for preview-persistence overlay edits Regenerate the overlay content hashes (refresh_manifest.mjs) for the four files touched by this branch so the CI drift gate passes. Mechanical: the committed manifest must match the overlay's hashes. --- packages/app-bundle/manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 56dd4a541..0f228bc52 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -636,11 +636,11 @@ "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", "packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "57241785d50c7e1b4f36808fce4f50145d372f3b60626e4fec93d9741b2a8b9d", + "packages/app/src/components/session/session-preview-tab.tsx": "8a0c63caef6b35191e764cc0a7d0e80988c1b07bc22adc4dab194582bb4d49f3", "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": "5ef5e18ced51e46591acef2500b97947f799c2485fcc648b9d49e95dee440fac", - "packages/app/src/components/session/session-preview-tree.ts": "13930fcd8a8ae9df4c37ba9ea306e162fd08f0e429249ddea21d937675d3ad7e", + "packages/app/src/components/session/session-preview-tree.test.ts": "7bf8b6c4fa4b363e2f3e268b2f79120703db75c94679698b73b8c6e5d19a637c", + "packages/app/src/components/session/session-preview-tree.ts": "b93148c65b1a6713fee446f3337b1a7fca807322c81004722a2238cb3186661f", "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", @@ -694,7 +694,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": "c00e8e89c9d1017de9379058c50d4e1ed0c21e0aa2a0f0582448035ca5005993", + "packages/app/src/pages/session/session-side-panel.tsx": "598258aecd1e0110e3324b8f060329ee2e181af6a2d23aa644f3f8ab28ed8909", "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", @@ -873,7 +873,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-28T20:52:32.278Z", + "extracted_at": "2026-09-28T21:15:16.511Z", "per_package": { "packages/app": { "A": 186, From 4ec3396c385e086f12657c174014b353ac6689b2 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Tue, 29 Sep 2026 15:58:08 -0400 Subject: [PATCH 14/80] fix(app): rework preview persistence to Approach A (survives reload + tab-switch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fragile reactive open-on-previewFile effect (the reopen/shift engine, which re-fired on every #1288 re-instantiation) with a single signature-guarded reactive restore that opens the persisted set idempotently — once per distinct value — so it survives async hydration and late session-key resolution and never loops or re-opens on a tab switch. - Delete the on(previewFile) open effect; live opens arrive via the mount message listener; a first-open-that-mounts is folded into the restore target (previewPaths ∪ active) with one deduped post-merge write to persist it. - Close reassigns previewActive to a remaining tab or null, so a closed file is never the active pointer that gets re-opened. - Extract the restore/close decisions into pure, tested helpers (previewRestoreTarget / previewMissingPaths / previewActiveAfterClose) that the component uses — the '1-of-N', reopen, net-new and close-reassign regressions are now unit-tested (18 pass). - Strip all debug diagnostics; refresh app-bundle manifest. Design of record + breaking-point analysis: #1640. Supersedes #1398. --- packages/app-bundle/manifest.json | 10 +- .../session/session-preview-tab.tsx | 107 +++++++++++------- .../session/session-preview-tree.test.ts | 52 ++++++--- .../session/session-preview-tree.ts | 37 ++++-- .../src/pages/session/session-side-panel.tsx | 5 +- 5 files changed, 138 insertions(+), 73 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 0f228bc52..f75126dcf 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -636,11 +636,11 @@ "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", "packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "8a0c63caef6b35191e764cc0a7d0e80988c1b07bc22adc4dab194582bb4d49f3", + "packages/app/src/components/session/session-preview-tab.tsx": "c336b2c4ec398190480579bb91f2bcd6b26ddd6b2e8b8bbb9654bd38128612aa", "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": "7bf8b6c4fa4b363e2f3e268b2f79120703db75c94679698b73b8c6e5d19a637c", - "packages/app/src/components/session/session-preview-tree.ts": "b93148c65b1a6713fee446f3337b1a7fca807322c81004722a2238cb3186661f", + "packages/app/src/components/session/session-preview-tree.test.ts": "cc3b40facb9e6cbc1dcda2254abc7a47a547ffdc1c4ded90aaa1fabefbda5e54", + "packages/app/src/components/session/session-preview-tree.ts": "a0136354eb5c90b0b50e333e9fc145984b37e38861dccff05b83c92837e7d339", "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", @@ -694,7 +694,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": "598258aecd1e0110e3324b8f060329ee2e181af6a2d23aa644f3f8ab28ed8909", + "packages/app/src/pages/session/session-side-panel.tsx": "d63fd6dc12f9420facd9c2e20fd01ad4895829b9463b08574bed02a661887c2b", "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", @@ -873,7 +873,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-28T21:15:16.511Z", + "extracted_at": "2026-09-29T19:57:16.640Z", "per_package": { "packages/app": { "A": 186, diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx index a066cc9c7..f42a3b2c9 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx @@ -3,7 +3,7 @@ * retained pool and are relocated between leaf slots without being recreated. */ -import { createEffect, createSignal, For, on, onCleanup, onMount, Show, type Accessor, type JSX } from "solid-js" +import { createEffect, createSignal, For, onCleanup, onMount, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" @@ -22,11 +22,13 @@ import { createPreviewWorkspace, movePreviewTab, openPreviewPath, + previewActiveAfterClose, previewLeafContaining, previewLeaves, previewMinimumExtent, + previewMissingPaths, + previewRestoreTarget, previewTabCount, - reconcilePreviewPaths, removePreviewPath, resizePreviewSplit, selectPreviewPath, @@ -102,6 +104,10 @@ export function SessionPreviewTab(props: { * its old purely-local, non-persisted behavior. #1398 + reload persistence. */ openPaths?: Accessor onOpenedPathsChange?: (paths: string[]) => void + /** Update the persisted active file (previewFile). Called when the active tab + * changes — including close, so a closed tab's path is never left as the + * active file to be re-opened on the next re-instantiation. */ + onActiveChange?: (path: string | null) => void /** True once the persisted layout store has hydrated. Reconciliation of the * persisted open-paths list is held until this flips true (the store loads * async); reconciling earlier clobbers the list to just the active file. */ @@ -128,6 +134,15 @@ export function SessionPreviewTab(props: { const openedPaths = () => previewLeaves(workspace().tree).flatMap((leaf) => leaf.tabs) + // Persist the current open set to the session view. Gated on hydration so a + // pre-hydration transient can't clobber the stored list; called EXPLICITLY at + // open/close (not from a reactive openedPaths watcher) so the stored list is + // always current and the restore below can never re-add a just-closed tab. + const syncOpenPaths = () => { + if (props.hydrated && !props.hydrated()) return + props.onOpenedPathsChange?.(openedPaths()) + } + const openPath = (path: string) => { const current = workspace() if (!previewLeafContaining(current.tree, path) && previewTabCount(current.tree) === MAX_PREVIEW_TABS) { @@ -136,53 +151,51 @@ export function SessionPreviewTab(props: { } setWorkspace(openPreviewPath(current, path)) setCapacityMessage(null) + syncOpenPaths() } - // Reconcile the persisted open-paths list into the workspace exactly once, - // AFTER the persisted store has hydrated (props.hydrated). Rebuild in the - // persisted order, unioning anything already open (e.g. the active file the - // previewFile effect opened first), then keep the active tab focused. + // The SOLE opener on mount / session re-instantiation. Restores the persisted + // open set — plus the active file, which covers a net-new open that mounted + // the Preview tab (the bridge sets previewFile before this component exists, + // and the mount message is missed) — by MERGING the target into the live + // workspace and focusing the active tab. // - // Gating on hydration is essential: the persisted store loads asynchronously, - // so before `hydrated` flips true `props.openPaths()` still reads empty. - // Reconciling then (or letting the up-sync run then) would clobber the - // persisted list down to just the active file — the "only 1 of N restored" - // bug. We hold both restore and up-sync until hydration is done. - let reconciled = false + // Why this shape (each clause earns its place against a failure we hit live): + // - Signature-guarded → runs once per DISTINCT target, never on its own + // workspace writes, so it cannot loop (the freeze) and cannot re-open on a + // plain tab switch (the reopen/shift). #1288 re-instantiation is inert here. + // - Reactive on the persisted accessors → fires when they resolve, so it + // survives async store hydration AND late session-key resolution. + // - Merge-only (never removes) → a file closed via removePath (which writes + // the shrunk list + reassigns previewActive) is absent from the target and + // is never re-added. + // - There is deliberately NO separate `on(previewFile)` open effect: that was + // the reopen engine. Live opens arrive through the mount message listener; + // the first-open-that-mounts is the `active` term of the target here. + // Split-pane / zoom / scroll are NOT restored — only the flat open set (#981). + let restoredSignature = "" createEffect(() => { - if (reconciled) return - if (props.hydrated && !props.hydrated()) return const persisted = props.openPaths?.() ?? [] - const finalPaths = reconcilePreviewPaths(persisted, openedPaths()) - if (persisted.length > 0) { - let next = createPreviewWorkspace(finalPaths) - const active = props.previewFile() - if (active && finalPaths.includes(active)) next = openPreviewPath(next, active) - setWorkspace(next) + const active = props.previewFile() + const target = previewRestoreTarget(persisted, active) + const signature = target.join("\u0000") + if (signature === restoredSignature) return + restoredSignature = signature + if (target.length === 0) return + const missing = previewMissingPaths(target, openedPaths()) + if (missing.length > 0) { + setWorkspace((current) => { + let next = current + for (const path of missing) next = openPreviewPath(next, path) + if (active) next = openPreviewPath(next, active) + return next + }) } - reconciled = true - props.onOpenedPathsChange?.(finalPaths) + // Persist a net-new active file that mounted the tab (not yet in the list). + // Deduped in the setter, so a plain re-instantiation writes nothing. + if (active && !persisted.includes(active)) syncOpenPaths() }) - createEffect( - on( - () => props.previewFile(), - (path) => { - if (path) openPath(path) - }, - ), - ) - - // Mirror the live open-paths set up to the persisted session view so a reload - // restores it. Held until reconciliation so a pre-hydration empty set never - // clobbers the persisted list. - createEffect( - on(openedPaths, (paths) => { - if (!reconciled) return - props.onOpenedPathsChange?.(paths) - }), - ) - onMount(() => { const handlePreviewFile = (event: MessageEvent) => { const data = event.data as { source?: string; kind?: string; path?: string } | undefined @@ -214,9 +227,16 @@ export function SessionPreviewTab(props: { } const removePath = (path: string) => { + const wasActive = props.previewFile() setWorkspace((current) => removePreviewPath(current, path)) setDirtyPaths(path, false) setHostMounts(path, undefined) + syncOpenPaths() + // If we just closed the active file, move the active pointer to a remaining + // tab (or null). Otherwise it stays pointing at the closed path and would be + // re-opened as the restore target on the next re-instantiation / tab switch. + const nextActive = previewActiveAfterClose(path, wasActive, openedPaths()) + if (nextActive !== wasActive) props.onActiveChange?.(nextActive) } const closePath = (path: string) => { @@ -520,7 +540,10 @@ export function SessionPreviewTab(props: { > setWorkspace((current) => selectPreviewPath(current, leaf.id, path))} + onChange={(path) => { + setWorkspace((current) => selectPreviewPath(current, leaf.id, path)) + props.onActiveChange?.(path) + }} class="shrink-0" classList={{ "preview-tab-strip": true }} > diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts index 7800d46eb..a3f7c72f1 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.test.ts @@ -3,10 +3,12 @@ import { createPreviewWorkspace, movePreviewTab, openPreviewPath, + previewActiveAfterClose, previewLeafByID, previewLeaves, previewMinimumExtent, - reconcilePreviewPaths, + previewMissingPaths, + previewRestoreTarget, removePreviewPath, setPreviewLeafZoom, } from "./session-preview-tree" @@ -128,24 +130,42 @@ describe("Preview open-paths persistence round-trip", () => { }) }) -// reconcilePreviewPaths is the merge that restore uses. The first case is the -// exact regression behind "only the active file came back on reload": the -// persisted list has all N, but the previewFile effect has only opened the -// active one when reconcile runs — the union must still be all N. -describe("reconcilePreviewPaths", () => { - test("restores the full persisted list when only the active file is open (the 1-of-N bug)", () => { - expect(reconcilePreviewPaths(["a", "b", "c"], ["c"])).toEqual(["a", "b", "c"]) - }) - - test("appends currently-open paths that were not persisted", () => { - expect(reconcilePreviewPaths(["a", "b"], ["b", "d"])).toEqual(["a", "b", "d"]) +// The pure restore/close decisions the persistence uses. Each test pins a +// specific regression we hit by hand across many rebuilds. +describe("preview persistence decisions", () => { + describe("previewRestoreTarget", () => { + test("folds in a net-new active file (first open that mounted the tab)", () => { + expect(previewRestoreTarget(["a"], "c")).toEqual(["a", "c"]) + }) + test("leaves the list unchanged when the active file is already tracked", () => { + expect(previewRestoreTarget(["a", "b"], "b")).toEqual(["a", "b"]) + }) + test("no active file → just the persisted list", () => { + expect(previewRestoreTarget(["a", "b"], null)).toEqual(["a", "b"]) + }) }) - test("keeps persisted order and de-duplicates when open is a subset", () => { - expect(reconcilePreviewPaths(["a", "b", "c"], ["a", "c"])).toEqual(["a", "b", "c"]) + describe("previewMissingPaths", () => { + test("restores the FULL set when only the active file is open (the 1-of-N bug)", () => { + expect(previewMissingPaths(["a", "b", "c"], ["c"])).toEqual(["a", "b"]) + }) + test("re-instantiation with everything already open opens nothing (no reopen)", () => { + expect(previewMissingPaths(["a", "b"], ["a", "b"])).toEqual([]) + }) + test("opens only the not-yet-open paths", () => { + expect(previewMissingPaths(["a", "b", "c"], ["a"])).toEqual(["b", "c"]) + }) }) - test("an empty persisted list keeps whatever is currently open", () => { - expect(reconcilePreviewPaths([], ["x"])).toEqual(["x"]) + describe("previewActiveAfterClose", () => { + test("closing the active tab moves active to the last remaining tab", () => { + expect(previewActiveAfterClose("b", "b", ["a", "c"])).toBe("c") + }) + test("closing the last/active tab clears active to null (no reopen pointer)", () => { + expect(previewActiveAfterClose("a", "a", [])).toBeNull() + }) + test("closing a background tab leaves active unchanged", () => { + expect(previewActiveAfterClose("a", "b", ["b", "c"])).toBe("b") + }) }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts index 6e36eaae9..ac568281a 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tree.ts @@ -39,14 +39,35 @@ export const createPreviewWorkspace = (paths: readonly string[] = []): PreviewWo nextPaneID: 1, }) -/** The reconciled open-paths list when restoring persisted preview tabs: the - * persisted order first, then any paths already open that were not persisted - * (e.g. the active file the previewFile effect opened before hydration). An - * empty persisted list keeps whatever is currently open. This is what fixes - * "only the active file restores" — restoring [a,b,c] when only [c] is open - * must yield [a,b,c], not [c]. */ -export const reconcilePreviewPaths = (persisted: readonly string[], open: readonly string[]): string[] => - persisted.length > 0 ? [...persisted, ...open.filter((path) => !persisted.includes(path))] : [...open] +// ── Preview persistence decisions (pure) ───────────────────────────────────── +// These are the restore/close decisions the SessionPreviewTab persistence uses. +// Kept pure and here (not inline in the component) so the exact regressions we +// hit — "only the active file restored", "closed tab reopened", "net-new open +// lost" — are unit-testable without a reactive harness. + +/** The set to restore into the workspace: the persisted open paths, plus the + * active file when it is a net-new open not yet tracked (a first open that + * mounted the Preview tab). Restoring [a,b,c] when only the active [c] is known + * must still yield all of [a,b,c] — never collapse to the active one. */ +export const previewRestoreTarget = (persisted: readonly string[], active: string | null): string[] => + active && !persisted.includes(active) ? [...persisted, active] : [...persisted] + +/** Which of `target` are not yet open — the paths to merge into the workspace. + * On a plain re-instantiation (everything already open) this is empty, so the + * restore is a no-op and nothing re-opens. */ +export const previewMissingPaths = (target: readonly string[], open: readonly string[]): string[] => { + const openSet = new Set(open) + return target.filter((path) => !openSet.has(path)) +} + +/** The active file after closing `closed`: unchanged when a background tab was + * closed; otherwise the last remaining tab, or null if none remain — so a + * closed active file is never left as the pointer that gets re-opened. */ +export const previewActiveAfterClose = ( + closed: string, + wasActive: string | null, + remaining: readonly string[], +): string | null => (closed === wasActive ? (remaining[remaining.length - 1] ?? null) : wasActive) export const previewLeaves = (tree: PreviewPane): PreviewLeaf[] => { if (tree.kind === "leaf") return [tree] diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx index 3eb76cbc1..1cb1cf02e 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx @@ -385,6 +385,7 @@ export function SessionSidePanel(props: { const previewFile = createMemo(() => view().previewFile.get()) const previewPaths = createMemo(() => view().previewPaths.get()) const setPreviewPaths = (paths: string[]) => view().previewPaths.set(paths) + const setPreviewActive = (path: string | null) => view().previewFile.set(path) createEffect( on(previewFile, (path) => { @@ -923,7 +924,7 @@ export function SessionSidePanel(props: { inert={activeTab() !== SESSION_PREVIEW_TAB} >
- activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} hydrated={layout.ready} /> + activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} onActiveChange={setPreviewActive} hydrated={layout.ready} />
@@ -1253,7 +1254,7 @@ export function SessionSidePanel(props: { inert={activeTab() !== SESSION_PREVIEW_TAB} >
- activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} hydrated={layout.ready} /> + activeTab() === SESSION_PREVIEW_TAB} openPaths={previewPaths} onOpenedPathsChange={setPreviewPaths} onActiveChange={setPreviewActive} hydrated={layout.ready} />
From d37c3d610997a87a02ee650d8882279f3a376c98 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Tue, 29 Sep 2026 16:36:51 -0400 Subject: [PATCH 15/80] chore(app): strip preview-persistence debug diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the temporary [PVKEY]/[PVDBG] console probes used to trace the cross-session bleed. Behavior unchanged; refresh app-bundle manifest. Known limitation (tracked separately): opening a file in one session's Preview can bleed into another session's Preview, because the prewarmer keeps backgrounded session views alive and the preview-file bridge message is an un-targeted window broadcast — every live SessionPreviewTab handles it. Not catastrophic; needs a foreground-session gate / session-targeted message to fix properly. --- packages/app-bundle/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index f75126dcf..357956c61 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -873,7 +873,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-29T19:57:16.640Z", + "extracted_at": "2026-09-29T20:36:51.065Z", "per_package": { "packages/app": { "A": 186, From 333cebd332f7b723a580855e9ba7b61b1e23d02a Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Tue, 29 Sep 2026 22:26:28 -0400 Subject: [PATCH 16/80] =?UTF-8?q?feat(app):=20responsive=20concise=20toggl?= =?UTF-8?q?e=20=E2=80=94=20hide=20when=20the=20composer=20is=20narrow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer wrapper becomes a named query container (.amicode-composer-cq); the concise toggle is wrapped and hidden via @container (max-width: 26rem) so it disappears when the composer itself is narrow — window narrowed OR side panel/split shrinks it — keeping the control row from crowding Send. Default (wide) visible; container-query-keyed so it adapts to layout, not just viewport. --- packages/app-bundle/manifest.json | 6 +++--- .../app/src/components/prompt-input-v2.tsx | 12 +++++++----- .../overlay/packages/app/src/index.css | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 769606f05..9ad7682ef 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -28,7 +28,7 @@ "packages/app/src/design-polish.css": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910", "packages/app/src/desktop-menu.ts": "350e0e9f2e4294bfe1b1f357b3cd2ea0908b14d20ca1e79fda2305db1958fb24", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", - "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", + "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", @@ -116,7 +116,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": "a982b27659c126e3148ffd3f9fca97da5a349aafde164871e4398b56aa12be6b", + "packages/app/src/components/prompt-input-v2.tsx": "12938d7b497ef971a3a809a7e0adf4845d148c673c79b55f345ac6de6adf73aa", "packages/app/src/components/prompt-input.tsx": "97a9bc55b46cf1f2d7c3e38110e736f7fa9db7f89121d3c013b9650a60d382d4", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", "packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd", @@ -874,7 +874,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-28T20:09:50.705Z", + "extracted_at": "2026-09-30T02:26:16.851Z", "per_package": { "packages/app": { "A": 187, diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx index cac14c73d..573117fc7 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx @@ -61,7 +61,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { const language = useLanguage() return ( -
+
- props.controller.concise.set(next)} - /> +
+ props.controller.concise.set(next)} + /> +
{bugReportEnabled() ? : undefined} } diff --git a/packages/app-bundle/overlay/packages/app/src/index.css b/packages/app-bundle/overlay/packages/app/src/index.css index 404975bb5..1a50b887c 100644 --- a/packages/app-bundle/overlay/packages/app/src/index.css +++ b/packages/app-bundle/overlay/packages/app/src/index.css @@ -414,3 +414,19 @@ .preview-tab-strip [data-slot="tabs-trigger"][data-selected] .tab-fileicon-mono { display: none !important; } + +/* amicode(#1633): responsive concise toggle. The composer wrapper is a named + query container (.amicode-composer-cq); hide the concise toggle when the + composer itself is narrow — the window narrowed OR a side panel / split + shrank it — so the control row never crowds the Send button. Default (wide) + is visible; hidden only below the breakpoint. Tune the max-width to taste. */ +.amicode-composer-cq { + container-type: inline-size; + container-name: amicode-composer; +} + +@container amicode-composer (max-width: 26rem) { + .amicode-concise-toggle-wrap { + display: none; + } +} From e47a12455e1c67d5777118d13d9b6438c52b4fa0 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Wed, 30 Sep 2026 08:36:09 -0400 Subject: [PATCH 17/80] =?UTF-8?q?fix(app):=20concise=20switch=20survives?= =?UTF-8?q?=20composer=20remount=20=E2=80=94=20session-keyed=20store=20(#1?= =?UTF-8?q?651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch was a createSignal in the composer controller (per-instance state), so the new-chat -> session handoff and the Changes-tab remount reset it to off. Hold it in a module-level in-memory store keyed per session; a new chat writes under its draft key, promoted to the session key at session creation beside the agent/model promotion. Closes #1651 --- packages/app-bundle/manifest.json | 16 ++-- .../app/src/components/prompt-input-v2.tsx | 26 ++++-- .../app/src/components/prompt-input/submit.ts | 19 ++-- .../app/src/context/concise-mode.test.ts | 89 +++++++++++++++++++ .../packages/app/src/context/concise-mode.ts | 49 ++++++++++ 5 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/context/concise-mode.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/context/concise-mode.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 9ad7682ef..d354d2635 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -116,7 +116,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": "12938d7b497ef971a3a809a7e0adf4845d148c673c79b55f345ac6de6adf73aa", + "packages/app/src/components/prompt-input-v2.tsx": "4a51a7823755514497497def61fd4b890f29e08573d7d727b303d31318f3b265", "packages/app/src/components/prompt-input.tsx": "97a9bc55b46cf1f2d7c3e38110e736f7fa9db7f89121d3c013b9650a60d382d4", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", "packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd", @@ -623,7 +623,7 @@ "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-stream-gap.test.ts": "68a94b03e75625531b2f2ad161ef9d0b93365f9edae154a797f82031b5bfc062", - "packages/app/src/components/prompt-input/submit.ts": "38621336b3c22a1e67cada6693a8e029fad963825ed4e2a6fec1ee39058f2278", + "packages/app/src/components/prompt-input/submit.ts": "ea6818c3d0570f382b355b31c23ce3c654d726b277c3413dcc2d343c2f4ea555", "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", @@ -859,7 +859,9 @@ "packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "10bcb79b117beebee04184fb48c1ad789e782e2f7ae4b0c68603edc00f03aee9", "packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts": "7da08d58765cdf2b6ce4648fc70de371402949f5bb7b75696f674d43f2d5365b", "packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "8baf16cad0992c5351b61b0c0fb27903bb25b17e1b25fc7c5d77f807c24413a6", - "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f" + "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f", + "packages/app/src/context/concise-mode.ts": "b03ac887222543e590d31781c944e69aca97bff32a661751dd817d46bf4ead64", + "packages/app/src/context/concise-mode.test.ts": "534258a1d82d66bd8b482bb3a8ca6a24d508328505abd0f7f52e1b75cdf57fa2" }, "symlinks": [ "packages/app/public/amico.svg" @@ -877,7 +879,7 @@ "extracted_at": "2026-09-30T02:26:16.851Z", "per_package": { "packages/app": { - "A": 187, + "A": 189, "M": 157, "D": 0 }, @@ -923,7 +925,7 @@ } }, "counts": { - "overlay_total": 852, + "overlay_total": 854, "deletions": 7, "server_coupled": 0 }, @@ -1779,7 +1781,9 @@ "packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "M", "packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts": "M", "packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "M", - "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "M" + "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "M", + "packages/app/src/context/concise-mode.ts": "A", + "packages/app/src/context/concise-mode.test.ts": "A" }, "upstream_base_archive_sha256": "4b5f2e5edfc796082a353fd09c1b1dec3e7a108a4b080fe3a86b12d9fa59fae6" } diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx index 573117fc7..e311ff18b 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx @@ -6,7 +6,9 @@ import { Icon } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" +import { useSearchParams } from "@solidjs/router" +import { bindConcise, conciseKey, conciseMode } from "@/context/concise-mode" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { ReportBugButton } from "@/components/report-bug-button" @@ -231,13 +233,21 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) return permission.isAutoAccepting(id, sdk().directory) }) - // amicode: concise-mode flag. A persistent per-session switch — flipping it - // sends nothing; it is read at submit time (createPromptSubmit's `concise`) to - // append a concise directive to the outgoing prose message. Lightweight by - // design: the response-presentation engine transport was parked in favor of - // the concise skill, and this drives that skill per sent message. - const [conciseOn, setConciseOn] = createSignal(false) - const concise = { on: conciseOn, set: setConciseOn } + // amicode: concise-mode flag. A per-session switch — flipping it sends + // nothing; it is read at submit time (createPromptSubmit's `concise`) to attach + // the concise directive to the outgoing prose message. #1651: held in the + // session-keyed concise store, NOT component state, so a composer remount (new + // chat → session, Changes tab, session switch) reads the value back. A new + // chat writes under its draft key; submit promotes it to the session key. + const [conciseSearch] = useSearchParams<{ draftId?: string }>() + const conciseKeyNow = () => { + const id = props.controls.session.id + return id + ? conciseKey.session(sdk().scope, sdk().directory, id) + : conciseKey.draft(sdk().scope, sdk().directory, conciseSearch.draftId) + } + const concise = bindConcise(conciseMode, conciseKeyNow) + const conciseOn = concise.on const submission = createPromptSubmit({ prompt, info, diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts index a0ed8bf50..b2877efbd 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts @@ -20,6 +20,7 @@ import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" +import { conciseKey, conciseMode } from "@/context/concise-mode" import { createPromptSubmissionState } from "./submission-state" import { normalizeSessionInfo } from "@/utils/session" import { Event } from "@opencode-ai/schema/event" @@ -245,11 +246,11 @@ type PromptSubmitInput = { * notice instead of optimistically posting into a dead tunnel. */ streamGap?: Accessor model?: ModelSelection - /** amicode: when true at send time, a concise directive is appended to the - * outgoing message (normal prose turns only) so the response comes back - * concise — the composer's concise switch drives this. Not a hidden channel: - * the directive rides with the sent message (the response-presentation - * engine transport that would make it invisible was parked). */ + /** amicode: when true at send time, the concise directive rides as the + * message's per-turn `system` instruction (normal prose turns only) so the + * response comes back concise — the composer's concise switch drives this. + * #1651: the switch is session-keyed; a new chat's value is promoted to the + * created session below. */ concise?: Accessor } @@ -389,6 +390,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { const permissionState = permission.currentServerState() const isNewSession = !params.id const shouldAutoAccept = isNewSession && input.autoAccept() + // amicode#1651: read the concise switch (and the new chat's draft key) NOW, + // before any await — once the session exists the composer's key moves to it. + const conciseAtSend = input.concise?.() ?? false + const conciseDraftKey = conciseKey.draft(sdk().scope, projectDirectory, search.draftId) const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory @@ -461,6 +466,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { model: { providerID: currentModel.provider.id, modelID: currentModel.id }, variant: variant ?? null, }) + // amicode#1651: the new chat's concise switch follows it into the session. + conciseMode.promote(conciseDraftKey, conciseKey.session(sdk().scope, sessionDirectory, session.id)) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) const draftID = search.draftId if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) @@ -490,7 +497,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { agent, model, variant, - system: (input.concise?.() ?? false) ? CONCISE_DIRECTIVE : undefined, + system: conciseAtSend ? CONCISE_DIRECTIVE : undefined, } const clearInput = () => { diff --git a/packages/app-bundle/overlay/packages/app/src/context/concise-mode.test.ts b/packages/app-bundle/overlay/packages/app/src/context/concise-mode.test.ts new file mode 100644 index 000000000..0c74718ff --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/concise-mode.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { bindConcise, conciseKey, createConciseStore } from "./concise-mode" +import { ServerScope } from "@/utils/server-scope" + +// amicode#1651 — the concise switch is per-SESSION state, not per-composer- +// instance state. These pin the store + the binding a composer controller uses, +// so a remount (new chat → session, Changes tab, session switch) cannot reset it. + +const scope = ServerScope.local +const dir = "/repo" + +describe("concise store (#1651)", () => { + test("an unknown key reads as off", () => { + const store = createConciseStore() + expect(store.get(conciseKey.session(scope, dir, "s1"))).toBe(false) + }) + + test("returns what was set, keys are independent", () => { + const store = createConciseStore() + const a = conciseKey.session(scope, dir, "a") + const b = conciseKey.session(scope, dir, "b") + store.set(a, true) + expect(store.get(a)).toBe(true) + expect(store.get(b)).toBe(false) + store.set(a, false) + expect(store.get(a)).toBe(false) + }) + + test("promote carries the draft value to the session and clears the draft", () => { + const store = createConciseStore() + const draft = conciseKey.draft(scope, dir, "d1") + const session = conciseKey.session(scope, dir, "s1") + store.set(draft, true) + store.promote(draft, session) + expect(store.get(session)).toBe(true) + expect(store.get(draft)).toBe(false) + }) + + test("promote from an unset draft leaves the session untouched", () => { + const store = createConciseStore() + const session = conciseKey.session(scope, dir, "s1") + store.set(session, true) + store.promote(conciseKey.draft(scope, dir, "d-none"), session) + expect(store.get(session)).toBe(true) + }) + + test("draft and session keys never collide", () => { + expect(conciseKey.draft(scope, dir, "x")).not.toBe(conciseKey.session(scope, dir, "x")) + expect(conciseKey.draft(scope, dir, undefined)).toBe(conciseKey.draft(scope, dir, undefined)) + }) +}) + +describe("concise binding — survives composer remount (#1651)", () => { + test("a second binding on the same key (a remount) sees the value set by the first", () => { + const store = createConciseStore() + const key = conciseKey.session(scope, dir, "s1") + const first = bindConcise(store, () => key) + first.set(true) + const remounted = bindConcise(store, () => key) + expect(remounted.on()).toBe(true) + }) + + test("new chat → session: the session-page binding reads the promoted value", () => { + const store = createConciseStore() + const draft = conciseKey.draft(scope, dir, "d1") + const session = conciseKey.session(scope, dir, "s1") + bindConcise(store, () => draft).set(true) + store.promote(draft, session) + expect(bindConcise(store, () => session).on()).toBe(true) + }) + + test("session creation failed (no promote): the draft keeps its value for the retry", () => { + const store = createConciseStore() + const draft = conciseKey.draft(scope, dir, "d1") + bindConcise(store, () => draft).set(true) + expect(bindConcise(store, () => draft).on()).toBe(true) + }) + + test("the binding follows its key: switching sessions restores each value", () => { + const store = createConciseStore() + let current = conciseKey.session(scope, dir, "a") + const binding = bindConcise(store, () => current) + binding.set(true) + current = conciseKey.session(scope, dir, "b") + expect(binding.on()).toBe(false) + current = conciseKey.session(scope, dir, "a") + expect(binding.on()).toBe(true) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/concise-mode.ts b/packages/app-bundle/overlay/packages/app/src/context/concise-mode.ts new file mode 100644 index 000000000..7692cc3e5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/concise-mode.ts @@ -0,0 +1,49 @@ +// amicode#1651: the composer's concise switch, held PER SESSION (in memory). +// +// The switch used to be a createSignal inside the composer controller, i.e. +// per-component-instance state: every composer remount reset it to off — the +// new-chat composer being replaced by the session composer on first send, the +// Changes tab unmounting the composer in the narrow layout, a session switch. +// Here the flag lives in a module-level store keyed by session, so a remounted +// composer reads the value back. A new chat has no session id yet: it writes +// under a DRAFT key, which the submit path promotes to the real session key +// the moment the session is created (beside the agent/model promotion). +// +// In memory by design — it resets on app reload; there is no persistence layer. +import { createStore } from "solid-js/store" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" + +export const conciseKey = { + session: (scope: ServerScope, directory: string, sessionID: string): string => + ScopedKey.from(scope, directory, "session", sessionID), + draft: (scope: ServerScope, directory: string, draftID: string | undefined): string => + ScopedKey.from(scope, directory, "draft", draftID ?? ""), +} + +export type ConciseStore = ReturnType + +export function createConciseStore() { + const [state, setState] = createStore>({}) + return { + get: (key: string) => state[key] === true, + set: (key: string, on: boolean) => setState(key, on), + /** Carry a draft's value to its newly created session and drop the draft entry. */ + promote: (from: string, to: string) => { + const value = state[from] + if (value === undefined) return + setState(to, value) + setState(from, undefined) + }, + } +} + +/** The app-wide instance the composer and the submit path share. */ +export const conciseMode = createConciseStore() + +/** A composer's view of the flag: always reads/writes the CURRENT key. */ +export function bindConcise(store: ConciseStore, key: () => string) { + return { + on: () => store.get(key()), + set: (next: boolean) => store.set(key(), next), + } +} From 177c5128958e61e8f290f5d34587185e2be4bde6 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 1 Oct 2026 08:39:20 -0400 Subject: [PATCH 18/80] =?UTF-8?q?feat(amico-run):=20amico=20fleet=20shard-?= =?UTF-8?q?rehome=20=E2=80=94=20the=20tested=20re-home=20for=20merged=20cl?= =?UTF-8?q?ient=20shards=20(#1658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shard merges are #1302's by-hand flow (shard-watch is detection-only, #1306), and the hand-run re-home of the 2026-10-01 macbook shard doubled path segments on two home prefixes — directories that exist nowhere, sessions invisible to every panel listing. The re-home is now tooling, not SQL by hand: one explicit prefix substitution (client home → hub home; doubling is structurally impossible — the candidate is exactly hubHome + from.slice(clientHome.length)), validated to exist on the hub before writing, falling back to the shard's original directory when it does not (invisible-but-truthful beats invisible-and-wrong). Dry-run by default; --apply performs per-id UPDATEs and re-reads the touched rows to verify. planRehome is a pure function of (rows, clientHome, hubHome, exists) with the real doubled paths as regression fixtures; the verb's impure edges (bridge, fs, env) are injectable, mirroring shard_watch.ts. Verified: 9/9 new tests; typecheck clean; full amico-run suite 1772 passed with one pre-existing month-boundary flake in sessions_verb (expected 2026-09 vs 2026-10, already under repair on the sessions-index-month-boundary branch, passes on main); dry-run + apply on a copy of the real canonical DB seeded with a fresh shard re-homed every row to a real existing hub path with verification true. --- packages/amico-run/src/fleet_verb.ts | 5 +- packages/amico-run/src/shard_rehome.ts | 199 +++++++++++++++++++ packages/amico-run/test/shard_rehome.test.ts | 161 +++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 packages/amico-run/src/shard_rehome.ts create mode 100644 packages/amico-run/test/shard_rehome.test.ts diff --git a/packages/amico-run/src/fleet_verb.ts b/packages/amico-run/src/fleet_verb.ts index 65e02b0fd..3ea6aa14a 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 @@ -592,7 +593,8 @@ const USAGE = "amico fleet launch --session --pid | " + 'amico fleet finish --session --outcome settled|crashed --pid [--step ""] | ' + "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 @@ -620,6 +622,7 @@ export function fleetVerb(argv: string[], deps: FleetVerbDeps = {}): VerbResult if (sub === "finish") return fleetFinish(rest); if (sub === "digest") return fleetDigest(rest); if (sub === "shard-watch") return shardWatch(rest); + 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/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/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); + }); +}); From ef6adaa9d92d29cd417c7840e757fdf13c804591 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 2 Oct 2026 07:34:02 -0400 Subject: [PATCH 19/80] =?UTF-8?q?feat(amico-run):=20corrections=20scan=20?= =?UTF-8?q?=E2=80=94=20mine=20session=20corrections=20into=20skills-integr?= =?UTF-8?q?ity=20findings=20(#1677)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REMEMBER-family verb the pasqal-campaign lesson demanded: user corrections observed in chat sessions used to evaporate; now they file. Deterministic marker pre-filter (recall) over READ-ONLY chat-DB gathers → the ONE jev client (precision: correction-noul + kind + severity, three questions one call, receipts, fail-open) → clusters file skill-finding/skill-proposal notes at status: open in the personal vault per the skills-integrity TEMPLATE shapes, cross-pass dedup by UPDATE appends; P0/P1 ping via the amico-slack contract; dry-run by default; watermark; a dashboards/user-corrections.md digest. --- packages/amico-run/src/corrections_verb.ts | 737 ++++++++++++++++++ packages/amico-run/src/verbs.ts | 21 + .../amico-run/test/corrections_verb.test.ts | 535 +++++++++++++ 3 files changed, 1293 insertions(+) create mode 100644 packages/amico-run/src/corrections_verb.ts create mode 100644 packages/amico-run/test/corrections_verb.test.ts diff --git a/packages/amico-run/src/corrections_verb.ts b/packages/amico-run/src/corrections_verb.ts new file mode 100644 index 000000000..8692a3918 --- /dev/null +++ b/packages/amico-run/src/corrections_verb.ts @@ -0,0 +1,737 @@ +// 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, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { 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 just under + * the archiver's destructive 0.95; tunable per the issue's Notes. */ +export const CORRECTIONS_CONFIDENCE_MIN = 0.90; + +/** 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). */ +export const DEFAULT_MAX_JEV = 200; + +/** 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. */ +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, + /\bshould be (?:using|doing)\b/i, + /\b(?:supposed to|you were supposed)\b/i, + /\bwhy did you\b/i, + /\byou should\b/i, + /\b(?:again|still) (?:wrong|not)\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.90 confidence, + * 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"; +} + +/** 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(join(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; +} + +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 frontmatter = + type === "skill-finding" + ? [ + "---", + "type: skill-finding", + `date: ${date}`, + `finding_id: ${id}`, + `severity: ${a[0]!.severity}`, + "status: open", + `session_id: "${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: "${first.candidate.sessionId}"`, + "evidence:", + ...a.map((o) => ` - "${o.candidate.sessionId} ${new Date(o.candidate.timeCreated).toISOString()}: ${o.candidate.userText.replace(/\s+/g, " ").trim().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). Naive + * frontmatter scan: the scan's own files carry flat scalar keys. */ +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; + } + if (!content.includes("corrections_scan: true")) continue; + if (!content.includes(`correction_kind: ${kind}`)) continue; + if (!content.includes(`session_id: "${sessionId}"`)) continue; + if (!/\bstatus:\s*open\b/.test(content)) 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 }[]; + 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) { + lines.push(`- unverified candidates (jev ${p.jevStatus} — nothing filed, listed for the eye): ${p.unverified.length}`); + for (const u of p.unverified) lines.push(` - ${u.sessionId}: ${u.excerpt}`); + } + 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). */ +async function jevCorrections( + candidates: CorrectionCandidate[], + deps: { env: NodeJS.ProcessEnv; jev?: JevDeps }, +): Promise<{ status: JevPassStatus; verdicts: Record }> { + const verdicts: Record = {}; + 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 }; + verdicts[c.messageId] = { error: res.error }; + continue; + } + 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 ──────────────────────────── + const admitted: AdmittedCorrection[] = []; + 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]); + } + + // ── 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 existing = findExistingScanFinding(findingsDir, first.candidate.sessionId, first.kind); + if (existing !== undefined) { + writeFileSync(existing, readFileSync(existing, "utf8").replace(/\n+$/, "") + renderUpdate(list, passIso)); + updated.push(existing); + continue; + } + seq++; + const { filename, content } = renderFinding(list, passIso, seq); + const target = join(findingsDir, filename); + writeFileSync(target, content); + filed.push({ path: target, type: findingTypeOf(first.kind), kind: first.kind, severity: first.severity, occurrences: list.length }); + } + writeWatermarkMs(env, now); + } + + // ── the digest (apply only) ────────────────────────────────────────────── + // "unverified" = candidates Jev never cleanly judged: the whole pass + // unavailable/disabled, or the individual call failed (error verdict) — + // digest rows for the eye, never findings. + const unverifiedList = judged + .filter((c) => jevStatus !== "ran" || (verdicts[c.messageId] !== undefined && verdicts[c.messageId]!.error !== undefined)) + .slice(0, DIGEST_UNVERIFIED_CAP) + .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, ...(jevStatus === "ran" ? [] : judged.slice(DIGEST_UNVERIFIED_CAP).map((c) => ({ sessionId: c.sessionId, excerpt: "…" })))].slice(0, DIGEST_UNVERIFIED_CAP), + overflow, + pinged: undefined, + }); + mkdirSync(join(digestFile, ".."), { recursive: true }); + if (existsSync(digestFile)) { + writeFileSync(digestFile, readFileSync(digestFile, "utf8").replace(/\n+$/, "") + "\n\n" + section.replace(/\n+$/, "") + "\n"); + } else { + writeFileSync(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 }; + writeFileSync(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, + 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 BOTH the CLI + * (amico.ts — SPINE_VERBS dispatch) and the MCP facade (mcp_serve.ts — the + * registry auto-publishes `amico_corrections`): one impl, two transports. */ +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/verbs.ts b/packages/amico-run/src/verbs.ts index 3aa69ce75..3963b867c 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -28,6 +28,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"; @@ -315,6 +316,25 @@ const notturno: Verb = { run: notturnoVerb, }; +// 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)", + run: (args) => correctionsVerb(args), +}; + export const SPINE_VERBS: Verb[] = [ catalog, vault, @@ -331,6 +351,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..344fe702e --- /dev/null +++ b/packages/amico-run/test/corrections_verb.test.ts @@ -0,0 +1,535 @@ +// `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_SCAN_DAYS, + effectiveSinceMs, + filingAdmits, + findExistingScanFinding, + isCorrectionCandidate, + pingWorthy, + readWatermarkMs, + renderDigestSection, + renderFinding, + renderUpdate, + severityOf, + type AdmittedCorrection, + type CorrectionCandidate, +} from "../src/corrections_verb.js"; +import { SPINE_VERBS } from "../src/verbs.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); + }); + + 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.90 corroborated by noul ≥ 0.5", () => { + expect(filingAdmits("behavior-gap", 0.92, 0.8)).toBe(true); + 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.89, 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)); + }); +}); + +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: [], + 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"); + }); +}); + +describe("verb registration", () => { + it("corrections is a real (non-stub) spine verb — CLI dispatch + MCP facade", () => { + 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/); + }); +}); + +// ── 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, 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); + expect(existsSync(join(vault, "amicode", "skills-integrity", "findings"))).toBe(false); + const digest = readFileSync(join(vault, "dashboards", "user-corrections.md"), "utf8"); + expect(digest).toContain("unverified candidates (jev unavailable"); + }); + + 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 }); + } + }); +}); From 2cea67265396ef8e5279807284177b39a799568c Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 2 Oct 2026 07:43:40 -0400 Subject: [PATCH 20/80] =?UTF-8?q?fix(amico-run):=20corrections=20scan=20tu?= =?UTF-8?q?ning=20=E2=80=94=20the=20pasqal=20backfill=20verdict=20(#1677)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tuned against the live 30-day acceptance run (the issue's named tuning loop): - recall markers strengthened (interrogative 'why are we', prescriptive 'we should', 'wtf', 'are we not', 'way too', display-drift 'not showing/in line') — the first pass missed the pasqal campaign's corrections entirely - filing gate recalibrated 0.90 → 0.80: jev reads true corrections as behavior-gap with noul ≥ 0.94 but kind-confidence in the 0.80–0.90 band; the noul floor (≥ 0.5) is the corroboration that keeps 0.80 honest - --max-jev default 200 → 400 (cost guard; the wider recall needs headroom) - admitted_detail in the result JSON — the dry-run's tuning surface - sqlite bridge maxBuffer 1MB → 128MB: the fleet-scale windowed gather blew node's default with ENOBUFS Acceptance (live dry-run, 30d window): 1220 scanned → 151 candidates → 17 admitted in 9 clusters, the pasqal campaign corrections included as P1 behavior-gap; nothing filed (dry-run). --- packages/amico-run/src/corrections_verb.ts | 40 +++++++++++++++---- packages/amico-run/src/sqlite_bridge.ts | 4 ++ .../amico-run/test/corrections_verb.test.ts | 12 +++++- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/amico-run/src/corrections_verb.ts b/packages/amico-run/src/corrections_verb.ts index 8692a3918..380afb5a7 100644 --- a/packages/amico-run/src/corrections_verb.ts +++ b/packages/amico-run/src/corrections_verb.ts @@ -49,16 +49,21 @@ const USAGE = 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 just under - * the archiver's destructive 0.95; tunable per the issue's Notes. */ -export const CORRECTIONS_CONFIDENCE_MIN = 0.90; + * 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). */ -export const DEFAULT_MAX_JEV = 200; +export const DEFAULT_MAX_JEV = 400; /** Digest unverified-entries cap (digest rows, never findings). */ export const DIGEST_UNVERIFIED_CAP = 20; @@ -66,7 +71,11 @@ 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. */ + * 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, @@ -80,11 +89,18 @@ export const CORRECTION_MARKERS: RegExp[] = [ /\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 you\b/i, - /\byou should\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, @@ -624,6 +640,15 @@ export async function correctionsScan(argv: string[], env: NodeJS.ProcessEnv, de 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: list[0]!.severity, + 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(); @@ -708,6 +733,7 @@ export async function correctionsScan(argv: string[], env: NodeJS.ProcessEnv, de jev_status: jevStatus, admitted: admitted.length, clusters: clusters.size, + admitted_detail: admittedDetail, filed, updated, unverified: unverifiedList.length, 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/test/corrections_verb.test.ts b/packages/amico-run/test/corrections_verb.test.ts index 344fe702e..9ba936c45 100644 --- a/packages/amico-run/test/corrections_verb.test.ts +++ b/packages/amico-run/test/corrections_verb.test.ts @@ -47,6 +47,13 @@ describe("the deterministic marker pre-filter", () => { 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", () => { @@ -94,14 +101,15 @@ describe("the questions builder + state fold", () => { // ── AC: the calibrated filing gate ──────────────────────────────────────────── describe("the filing gate + severity ladder", () => { - it("admits kind-carrying corrections at ≥ 0.90 corroborated by noul ≥ 0.5", () => { + 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.89, 0.9)).toBe(false); + 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); From 2ae58dd7ec288ee34d3cf21ff299d6f171bee637 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 2 Oct 2026 12:03:36 -0400 Subject: [PATCH 21/80] =?UTF-8?q?fix(amico-run):=20corrections=20scan=20?= =?UTF-8?q?=E2=80=94=20independent-review=20hardening=20(#1677)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-merge reviewer pass (never the implementer) found the vault-write layer too trusting; all findings fixed and pinned: IMPORTANT: - YAML scalar escaping for untrusted user text in evidence lines — a bare quote could end the scalar and inject frontmatter keys (file integrity + the dedup matcher both read it); backslash+quote escaped, whitespace collapsed - collision-safe finding filenames — the pass-scoped seq could regenerate a triaged closed finding's exact name and silently overwrite it; bump until free - atomic vault writes (tmp+rename, the house pattern) on every finding/UPDATE/ digest path — a crash mid-write must never truncate the original claim MINOR: - findExistingScanFinding scans the frontmatter BLOCK only (body quotes could cross-match a pasted finding-like message) - cluster severity = worst occurrence (a P2 cluster escalating to P1/P0 now files — and pings — at its true severity) - true unverified count in JSON + digest overflow line (was silently capped) - admission gated on a fully-ran pass (partial-failure verdicts no longer overstate the dry-run output) - jev circuit breaker: 5 consecutive transport failures degrade the pass (a hanging endpoint no longer costs --max-jev × 10s of serial waiting) - stale filingAdmits docstring (0.90 → 0.80), merged fs imports, dirname Test gaps pinned: chmod-444 read-only AC, hostile-text YAML escaping, closed-finding collision path, watermark-not-advanced on fail-open, escalation ping. Suite: 37 corrections tests, package 1866 passed. --- packages/amico-run/src/corrections_verb.ts | 145 +++++++++++++----- .../amico-run/test/corrections_verb.test.ts | 101 +++++++++++- 2 files changed, 205 insertions(+), 41 deletions(-) diff --git a/packages/amico-run/src/corrections_verb.ts b/packages/amico-run/src/corrections_verb.ts index 380afb5a7..76f918e46 100644 --- a/packages/amico-run/src/corrections_verb.ts +++ b/packages/amico-run/src/corrections_verb.ts @@ -26,11 +26,12 @@ // 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, writeFileSync } from "node:fs"; +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 { join } from "node:path"; +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"; @@ -215,9 +216,10 @@ export interface CorrectionVerdict { error?: string; } -/** The filing gate (pure): a kind-carrying correction at ≥ 0.90 confidence, - * corroborated by the noul at ≥ 0.5. `not-a-correction` never files; - * anything below threshold never files. */ +/** 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; @@ -236,6 +238,15 @@ 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. */ @@ -306,7 +317,7 @@ export function readWatermarkMs(env: NodeJS.ProcessEnv): number | undefined { export function writeWatermarkMs(env: NodeJS.ProcessEnv, ms: number): void { const file = correctionsWatermarkFile(env); - mkdirSync(join(file, ".."), { recursive: true }); + 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"); } @@ -357,6 +368,34 @@ function slugOf(s: string): string { 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); } @@ -390,6 +429,7 @@ export function renderFinding(a: AdmittedCorrection[], passIso: string, seq: num ) .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" ? [ @@ -397,9 +437,9 @@ export function renderFinding(a: AdmittedCorrection[], passIso: string, seq: num "type: skill-finding", `date: ${date}`, `finding_id: ${id}`, - `severity: ${a[0]!.severity}`, + `severity: ${severity}`, "status: open", - `session_id: "${first.candidate.sessionId}"`, + `session_id: "${yamlScalar(first.candidate.sessionId)}"`, `source: "corrections-scan pass ${passIso}"`, "skills: []", `tags: [corrections-scan, ${first.kind}]`, @@ -414,9 +454,9 @@ export function renderFinding(a: AdmittedCorrection[], passIso: string, seq: num `date: ${date}`, `proposal_id: ${id}`, "status: open", - `session_id: "${first.candidate.sessionId}"`, + `session_id: "${yamlScalar(first.candidate.sessionId)}"`, "evidence:", - ...a.map((o) => ` - "${o.candidate.sessionId} ${new Date(o.candidate.timeCreated).toISOString()}: ${o.candidate.userText.replace(/\s+/g, " ").trim().slice(0, 160)}"`), + ...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", @@ -431,8 +471,10 @@ export function renderFinding(a: AdmittedCorrection[], passIso: string, seq: num } /** Find an existing OPEN scan finding for the same session + kind — the - * cross-pass dedup (append an UPDATE, never a duplicate file). Naive - * frontmatter scan: the scan's own files carry flat scalar keys. */ + * 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()) { @@ -443,10 +485,13 @@ export function findExistingScanFinding(dir: string, sessionId: string, kind: st } catch { continue; } - if (!content.includes("corrections_scan: true")) continue; - if (!content.includes(`correction_kind: ${kind}`)) continue; - if (!content.includes(`session_id: "${sessionId}"`)) continue; - if (!/\bstatus:\s*open\b/.test(content)) 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; @@ -469,6 +514,7 @@ export function renderDigestSection(p: { 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 { @@ -478,9 +524,10 @@ export function renderDigestSection(p: { `- 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) { - lines.push(`- unverified candidates (jev ${p.jevStatus} — nothing filed, listed for the eye): ${p.unverified.length}`); + 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}`); @@ -532,21 +579,29 @@ export interface CorrectionsDeps { /** 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). */ + * curation loop convention (no call is made after unavailability is known). + * Circuit breaker: 5 consecutive transport/http/malformed failures degrade + * the pass to `error` — at --max-jev 400 a hanging endpoint must not cost + * 400 × 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; verdicts: Record }> { +): 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; @@ -628,12 +683,17 @@ export async function correctionsScan(argv: string[], env: NodeJS.ProcessEnv, de } // ── 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[] = []; - 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 }); + 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) { @@ -645,7 +705,7 @@ export async function correctionsScan(argv: string[], env: NodeJS.ProcessEnv, de session_id: list[0]!.candidate.sessionId, session_title: list[0]!.candidate.sessionTitle, kind: list[0]!.kind, - severity: list[0]!.severity, + severity: maxSeverity(list), occurrences: list.length, quotes: list.map((o) => o.candidate.userText.replace(/\s+/g, " ").trim().slice(0, 140)), })); @@ -660,28 +720,35 @@ export async function correctionsScan(argv: string[], env: NodeJS.ProcessEnv, de 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) { - writeFileSync(existing, readFileSync(existing, "utf8").replace(/\n+$/, "") + renderUpdate(list, passIso)); + 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++; - const { filename, content } = renderFinding(list, passIso, seq); - const target = join(findingsDir, filename); - writeFileSync(target, content); - filed.push({ path: target, type: findingTypeOf(first.kind), kind: first.kind, severity: first.severity, occurrences: list.length }); + 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, or the individual call failed (error verdict) — - // digest rows for the eye, never findings. + // 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)) - .slice(0, DIGEST_UNVERIFIED_CAP) .map((c) => ({ sessionId: c.sessionId, excerpt: c.userText.replace(/\s+/g, " ").trim().slice(0, 120), @@ -697,15 +764,15 @@ export async function correctionsScan(argv: string[], env: NodeJS.ProcessEnv, de candidates: all.length, jevStatus, filed, - unverified: [...unverifiedList, ...(jevStatus === "ran" ? [] : judged.slice(DIGEST_UNVERIFIED_CAP).map((c) => ({ sessionId: c.sessionId, excerpt: "…" })))].slice(0, DIGEST_UNVERIFIED_CAP), + unverified: unverifiedList.slice(0, DIGEST_UNVERIFIED_CAP), + unverifiedOverflow: Math.max(0, unverifiedList.length - DIGEST_UNVERIFIED_CAP), overflow, pinged: undefined, }); - mkdirSync(join(digestFile, ".."), { recursive: true }); if (existsSync(digestFile)) { - writeFileSync(digestFile, readFileSync(digestFile, "utf8").replace(/\n+$/, "") + "\n\n" + section.replace(/\n+$/, "") + "\n"); + atomicWrite(digestFile, readFileSync(digestFile, "utf8").replace(/\n+$/, "") + "\n\n" + section.replace(/\n+$/, "") + "\n"); } else { - writeFileSync(digestFile, DIGEST_HEADER + "\n" + section.replace(/\n+$/, "") + "\n"); + 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); @@ -714,7 +781,7 @@ export async function correctionsScan(argv: string[], env: NodeJS.ProcessEnv, de 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 }; - writeFileSync(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`); + 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`); } } diff --git a/packages/amico-run/test/corrections_verb.test.ts b/packages/amico-run/test/corrections_verb.test.ts index 9ba936c45..ac7a733a3 100644 --- a/packages/amico-run/test/corrections_verb.test.ts +++ b/packages/amico-run/test/corrections_verb.test.ts @@ -23,6 +23,7 @@ import { filingAdmits, findExistingScanFinding, isCorrectionCandidate, + maxSeverity, pingWorthy, readWatermarkMs, renderDigestSection, @@ -199,6 +200,28 @@ describe("finding rendering (TEMPLATE shapes)", () => { 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)", () => { @@ -241,6 +264,7 @@ describe("the digest section", () => { jevStatus: "ran", filed: [{ path: "/v/finding.md", type: "skill-proposal", kind: "behavior-gap", severity: "P1", occurrences: 2 }], unverified: [], + unverifiedOverflow: 0, overflow: 0, pinged: undefined, }); @@ -249,6 +273,24 @@ describe("the digest section", () => { 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", () => { @@ -417,18 +459,73 @@ describe("the scan flow", () => { expect(readdirSync(join(vault, "amicode", "skills-integrity", "findings"))).toHaveLength(1); }); - it("fail-open: jev unavailable files NOTHING, and the digest lists the candidates as unverified", async () => { + 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); + 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; From 915d731f9b268becfe2966a56865ac5000f9d888 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 4 Oct 2026 08:08:23 -0400 Subject: [PATCH 22/80] =?UTF-8?q?feat(skills):=20formulation-display=20?= =?UTF-8?q?=E2=80=94=20canonical=20optimization-problem=20presentation=20+?= =?UTF-8?q?=20QILC/Piccolissimo=20display=20sets=20(#1705)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The papers' classic forms (2305.03261, 2508.19075): continuous core, direct collocation, indirect/GRAPE contrast, free-time/min-time with the floor-margin lesson, robustness ensembles. Intonatissimo gains the QILC form (measurement model, matching NLP, ILC correction) and its required display set (measurement models, pulse+correction plots per iteration, convergence). Piccolissimo gains the spline-NLP form and its display set. Lint: three cards, zero findings (pre-existing main failures unrelated). --- .../skills/formulation-display/SKILL.md | 149 ++++++++++++++++++ .../extension/skills/intonatissimo/SKILL.md | 52 ++++++ .../extension/skills/piccolissimo/SKILL.md | 42 +++++ 3 files changed, 243 insertions(+) create mode 100644 packages/extension/skills/formulation-display/SKILL.md diff --git a/packages/extension/skills/formulation-display/SKILL.md b/packages/extension/skills/formulation-display/SKILL.md new file mode 100644 index 000000000..acb56a77e --- /dev/null +++ b/packages/extension/skills/formulation-display/SKILL.md @@ -0,0 +1,149 @@ +--- +name: formulation-display +description: Present an optimization problem in classic form — the papers' notation (Direct Collocation for Quantum Optimal Control, arXiv:2305.03261; Universal Dynamics with Globally Controlled Analog Quantum Simulators, arXiv:2508.19075): continuous core, direct collocation, indirect/GRAPE contrast, free-time/min-time, robustness ensembles, plus the per-solve display set. Use when writing a problem statement for a PI-report finding, demo, paper, or spec, or when a report/paper must show what was actually solved. +agents: [researcher, experimenter, engineer] +surface: public +scenarios: [pi-report-findings, demo-writeup, paper-section, spec-cards] +--- + +# Formulation display — the problem in classic optimization form + +House style, from the two papers: whenever a solve is described beyond a +headline number, the optimization problem itself is **written out** — +objective, constraints, dynamics — in the papers' notation. The +direct-collocation paper (arXiv:2305.03261) is the canonical source for the +DIRCOL forms; the global-control paper's Appendix G (arXiv:2508.19075) is +the canonical source for the continuous core and the direct/indirect +contrast. Equation pointers below cite those sources. + +The LaTeX idiom for all forms (the papers' `split` layout): + +```latex +\begin{split} +\underset{}{\text{minimize}}\quad & \\ +\text{subject to}\quad & \\ + & \\ + & +\end{split} +``` + +## Form 1 — the continuous core + +Source: arXiv:2508.19075, App. G, Eq. (111). Over time-dependent state and +control trajectories: + +$$\min_{x(t),u(t)} \int_0^T \ell(x(t),u(t))\,dt + \ell_T(x(T)) +\quad \text{s.t.} \quad \dot{x}(t) = f(x(t),u(t),t),\; x(0)=x_\mathrm{init}$$ + +with the quantum Hamiltonian written in the affine control form +(arXiv:2305.03261, Eq. (2)): + +$$H(\mathbf{a}(t)) := H_0 + \sum_i a_i(t)\,H_i$$ + +Show this form when introducing a problem class or writing for an audience +that thinks in continuous time; it is the parent of every discrete form +below. + +## Form 2 — direct collocation (the house default) + +Source: arXiv:2305.03261, Eqs. (9), (10), (11). States and controls are +decision variables jointly; dynamics are enforced as **equality constraints** +between knots: + +$$\begin{split} +\underset{z_{1:N}}{\text{minimize}}\quad & J(z_{1:N}) \\ +\text{subject to}\quad & f(z_k, z_{k+1}) = 0 \quad \text{(collocation, e.g. Pad\'e-integrator: } U_{k+1} - e^{-iH(\mathbf{a}_k)\Delta t}U_k\text{)} \\ +& U_1 = I_{2n} \\ +& |\mathbf{a}_k| \le \mathbf{a}_{\max} +\end{split}$$ + +with $z_k$ packing states and controls per knot. Two load-bearing practical +notes from the paper, both worth stating when the form appears: + +- **Isomorphic representation.** Complex objects enter the real-valued NLP + via the isomorphism (reals-then-imaginary parts); the paper's tilde + notation ($\widetilde{U}$, $\mathrm{iso}(\cdot)$) is the house notation. +- **Solver.** A large, sparse nonlinear program solved by an interior-point + solver (IPOPT); dynamically infeasible initial guesses are legitimate + starting points and often converge fast — a defining property of direct + methods. + +Show this form for essentially every Piccolo solve, spec card, or report +finding that formulates a new problem. + +## Form 3 — indirect (GRAPE) contrast + +Source: arXiv:2508.19075, Eqs. (114)–(115). Optimize over controls only; +states are retrieved by propagation: + +$$\underset{u_{1:N-1}}{\text{minimize}} \sum_{k=1}^{N-1} \ell(x_k(u_{1:k-1}),u_k) + \ell_T(x_N) +\quad \text{s.t.} \quad |u(t)| \le u_{\max}$$ + +Show this form **only as a contrast**: fewer variables, but every +$x_k$-involving gradient must propagate dependence through the dynamics — +intermediate-state constraints are hard to enforce and the control landscape +is rough. The papers use it to justify the direct method; a report should +do the same, not present it as a competing option we run. + +## Form 4 — free-time / minimum-time + +Source: arXiv:2305.03261, Eq. (22) and its warm-start continuation. Stage +one solves a free-time problem (knot times $\Delta t_k$ are decision +variables) for a regularized objective: + +$$\begin{split} +\underset{z_{1:N}}{\text{minimize}}\quad & J_0 = Q\,\ell(\widetilde{U}_N) + R(\mathbf{a}_{1:N-1}) \\ +\text{subject to}\quad & \mathbf{P}^{(4)}(\widetilde{U}_{k+1},\widetilde{U}_k,\mathbf{a}_k,\Delta t_k) = 0 \\ +& \widetilde{U}_1 = I_{2n}, \quad |\mathbf{a}_k| \le \mathbf{a}_{\max} +\end{split}$$ + +Stage two warm-starts a time-minimizing objective +$J = J_0 + D\sum_k \Delta t_k$ with a final-fidelity floor +$\mathcal{F}(U_N) \ge \bar{\mathcal{F}}$ preventing the duration term from +trading away the gate. **Always state the floor's relation to the achieved +stage-one fidelity** — a floor at or above the achieved value is a +guaranteed violation (a recorded lesson from the OQC campaign: set floors +*below* achieved F with margin). + +## Form 5 — robustness ensembles + +House form for `SamplingProblem`: minimize a weighted sum (or the worst +case) over an ensemble of perturbed systems: + +$$\underset{z}{\text{minimize}} \sum_j w_j\, J(z;\theta_j) +\quad \text{s.t.} \quad \text{(per-member dynamics)},$$ + +with $\theta_j$ the perturbation ensemble (drive scale, detuning, position +jitter). When reporting: **nominal and worst-case are both stated, legs +named** — the display-conventions honesty bar. + +## The per-solve display set + +What accompanies a stated problem, composing the plot skill's per-tier +standards (never duplicating them): + +| Solve type | Always show | +| --- | --- | +| Gate / state synthesis | The stated form; pulse plot; populations or unitary-distance trace; convergence trace ($J$, $\texttt{inf\_pr}$, $\texttt{inf\_du}$) | +| Free-time / min-time | The stated form **plus** the duration/fidelity trajectory and the floor margin | +| Robustness ensemble | Nominal + worst-case, members overlaid; the per-member breakdown table | +| Any free-phase solve | The virtual-Z frame note — which phases were free and their optimized values | + +## Where these render + +- **PI-report findings** (`pi-report` fill contract, amicode #1700): a + finding that formulates a new problem carries its classic form inline; + findings that reuse an established formulation name it and cite the spec. +- **Demo/paper write-ups**: the form opens the methods section; the + run-dir artifacts back every number (display-conventions: a number + without an artifact is a claim). +- **Spec cards**: the falsifiable acceptance entries pair with the formal + statement — state the problem, then the gates that adjudicate it. + +## Honesty bars (inherit `display-conventions`) + +- Legs named, never merged; the researcher's notation; provenance stamps. +- Package-API terms are **mapped to math** here — the report shows math, + the run dir shows code. When mapping, cite the package's own docstring + or verified usage card (never invent an equation the package doesn't + solve — the anti-gaming rule applies to formulations too). diff --git a/packages/extension/skills/intonatissimo/SKILL.md b/packages/extension/skills/intonatissimo/SKILL.md index c6b16cef5..87be1019c 100644 --- a/packages/extension/skills/intonatissimo/SKILL.md +++ b/packages/extension/skills/intonatissimo/SKILL.md @@ -178,3 +178,55 @@ c = capture(sub, direction) # fraction of the dire > wrong, `using Intonatissimo` fails at > load with a missing-name error — re-pair the environment; the calls above do > not change. + +## Display — the QILC problem in classic form + +A QILC write-up (report finding, demo, paper section) states the problem +in classic optimization form, in the `formulation-display` house style, +before any results. Three equations, all from the constructors above: + +**The measurement model.** Operators $\{M_j\}$ evaluated on the state at +measurement knots $t_k$: + +$$y_k = \big(\langle\psi(t_k)| M_j |\psi(t_k)\rangle\big)_{j}, \qquad +y_{\text{model}} = h(z) \ \text{(NLP rollout)}, \qquad +y_{\text{exp}} = h_{\text{device}}(\text{pulse}) + \text{noise}$$ + +**The measurement-matching subproblem** (`build_subproblem`): control NLP +minimizing measurement error against shifted targets under a trust region: + +$$\begin{split} +\underset{z}{\text{minimize}}\quad & +\lVert y(z) - y_{\text{target}} \rVert^2_{Q_{\text{meas}}} ++ R_{\text{tr}}\lVert u - u_{\text{ref}}\rVert^2 ++ R_{\theta}\lVert\theta - \theta_{\text{ref}}\rVert^2 \\ +\text{subject to}\quad & \text{trajectory dynamics (collocation)},\quad +\text{control + global bounds} +\end{split}$$ + +with the option to compose the original objective (infidelity) in when +measurement-matching alone does not drive fidelity. + +**The ILC correction** (`compute_ilc_targets`): the model error estimate +$\hat\varepsilon = y_{\text{exp}} - y_{\text{model}}$ is pre-compensated +into the targets, +$y_{\text{target}} = y_{\text{goal}} + y_{\text{model}} - y_{\text{exp}}$, +and the loop iterates solve $\to$ apply $\to$ measure with Armijo +backtracking acceptance and the trust-region schedule $\gamma$; the +convergence criterion is measurement error $\le$ `tol`. + +### The required display set (a QILC write-up always carries) + +1. **The measurement model, drawn**: the operators, their knots, the goal + targets $y_{\text{goal}}$ — a reader must see *what is being matched* + before any correction makes sense. +2. **Pulse + correction plots, per iteration**: the applied pulse against + the nominal, and the correction $\delta u$ per accepted iteration — + the ILC's actual movement. +3. **Convergence**: measurement error and objective per outer iteration, + accepted/rejected Armijo steps marked, trust-region radius against + iteration. + +A QILC result reported without these three is incomplete — the +calibration tier's results live or die on whether the measurement model +was honest, and only these displays let the reader check. diff --git a/packages/extension/skills/piccolissimo/SKILL.md b/packages/extension/skills/piccolissimo/SKILL.md index 428aa9274..9e651e3ac 100644 --- a/packages/extension/skills/piccolissimo/SKILL.md +++ b/packages/extension/skills/piccolissimo/SKILL.md @@ -172,3 +172,45 @@ solve!(qcp; max_iter = 60) knot data when you have it. - `load_pulse` returns only the pulse object; bundle metadata (fidelity, gate name) with `JLD2.jldsave(...; pulse = ..., fidelity = ...)` at save time. + +## Display — the spline NLP in classic form + +A Piccolissimo write-up states the problem in the `formulation-display` +house style, with the spline parameterization explicit. The controls are +**spline coefficients at knots** (not per-timestep samples): the emitted +waveform is by construction the spline the integrator integrates — that +is the package's defining honesty property (spline-faithful dynamics). +Classic form: + +$$\begin{split} +\underset{\text{knot values } \mathbf{a}_{1:K},\ (\Delta t,\ \theta)}{\text{minimize}}\quad & +\ell(\widetilde{U}_N) + R\,\|\text{control regularization}\| \\ +\text{subject to}\quad & +\text{spline-faithful collocation: } f(z_k, z_{k+1}; \text{spline}(\mathbf{a})) = 0 \\ +& \widetilde{U}_1 = I,\quad \text{amplitude bounds},\quad +\text{slew bound } \|\mathrm{d}u\| \le \texttt{du\_bound} +\end{split}$$ + +with $\theta$ the (optionally free) globals and the second-order mode +Gauss–Newton (matrix-free). State which integrator ran (`:spline` vs the +Piccolo default) — the two are not interchangeable claims (the +Piccolissimo-vs-Piccolo integrator comparison is a recorded lesson, not +lore). + +### The required display set + +1. **The emitted spline against the optimizer's knots** — the device + plays the spline, not the knot values; the two rendered together + expose any knot-vs-emitted mismatch. +2. **Gauss–Newton convergence** — the objective and constraint violation + trace; Altissimo's callback fields (`f_val`, `eq_viol`, `kkt_error`) + are the honest convergence line. +3. **Slew/roughness against the bound** — the `du_bound` margin, since + the smoothness axis is Piccolissimo's reason for existing. + +## Cross-reference + +The adjoint-robustness objective's own form (when used) is stated +alongside, from the robustness section above — a Piccolissimo report +never presents robustness numbers without the objective that produced +them. From ec56ecea6538a7b1367db4c47955696357d1a980 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 4 Oct 2026 08:12:47 -0400 Subject: [PATCH 23/80] =?UTF-8?q?feat(skills):=20the=20component=20invento?= =?UTF-8?q?ry=20=E2=80=94=20integrators,=20objective=20terms,=20constraint?= =?UTF-8?q?s,=20solvers=20in=20classic=20form,=20full=20disclosure=20(#170?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every name verified against package source (MagnusGL4/Adapt4/Chebyshev cores, Daleckii-Krein, split-operator Strang, Padé-13 internal step evaluation); forms follow the papers. The completeness rule: a stated problem lets the reader reconstruct the NLP — trajectory type, integrator core, every objective term with weight, every constraint with bound, solver. --- .../skills/formulation-display/SKILL.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/extension/skills/formulation-display/SKILL.md b/packages/extension/skills/formulation-display/SKILL.md index acb56a77e..411fd267f 100644 --- a/packages/extension/skills/formulation-display/SKILL.md +++ b/packages/extension/skills/formulation-display/SKILL.md @@ -129,6 +129,69 @@ standards (never duplicating them): | Robustness ensemble | Nominal + worst-case, members overlaid; the per-member breakdown table | | Any free-phase solve | The virtual-Z frame note — which phases were free and their optimized values | +## The component inventory — full disclosure + +The user is fully informed: a stated problem lets the reader reconstruct +the NLP. Every formulation statement names the **trajectory type, +integrator (with its core), every objective term with its weight, every +constraint with its bound, and the solver**. The tables below map API to +classic form. Every name is verified against package source; the classic +forms follow the papers' notation. + +### Integrators (the dynamics constraint each one enforces) + +| Integrator | Form it enforces | Notes | +| --- | --- | --- | +| `HermitianExponentialIntegrator` (unitary / ket / multiket / sampling variants) | $U_{k+1} = e^{-iH(\mathbf{a}_k)\Delta t} U_k$ | The step's exponential evaluates via an internal Padé-13 approximation (source: `integrators_exponential/_exponential_integrators.jl`). The published Padé-integrator collocation form is the paper's $P^{(4)}$ (arXiv:2305.03261 §III-B) | +| `NonhermitianExponentialIntegrator` (density / multidensity) | $\rho_{k+1} = e^{\mathcal{L}(\mathbf{a}_k)\Delta t}\rho_k$, $\mathcal{L}(\cdot) = -i[H,\cdot] + \text{dissipation}$ | Daleckii–Krein for the Fréchet derivative (`daleckii_krein.jl`) | +| `BilinearIntegrator` | Piecewise-constant control, first-order | The simplest collocation step | +| `SplineIntegrator` (Piccolo) | The constraint integrates the **exact spline waveform** reconstructed from knot values — the emitted pulse and the optimized pulse are the same object | Per trajectory type (unitary/ket/multiket/density/multidensity) | +| Piccolissimo `SplineIntegrator` cores | Magnus series: `MagnusGL4Alg`, `MagnusAdapt4Alg`; `ChebyshevAlg` (ket); Duhamel for density (`density_duhamel.jl`) | State which core ran — they are different approximations, and "Magnus does not apply to the dissipative density ODE (non-skew rhs)" is a recorded lesson | +| `SplitOperatorIntegrator` (Piccolissimo) | Strang split-step, matrix-free: kinetic factor $e^{-ik^2\Delta t/2m}$ (control-independent) + potential kicks; analytic JVP/VJP, no AD near the FFT | Atom-transport venue (issue #270) | +| Tsit5 (explicit RK) | Adaptive rollout only | **Never** a collocation constraint; verification/rollout path — quote its agreement separately (density paths are Tsit5-primary + Tsit5-cross-check: weaker integrator independence, a recorded honesty note) | + +### Objective terms + +| Term | Classic form | Source | +| --- | --- | --- | +| `UnitaryInfidelityObjective` | $\ell(U) = 1 - \frac{1}{n}\lvert\mathrm{tr}(U_\text{goal}^\dagger U)\rvert$ | Paper Eq. (3), arXiv:2305.03261 | +| `KetInfidelityObjective` | $1 - \lvert\langle\psi_\text{goal}\vert\psi_N\rangle^2\rvert$ | `objectives.jl` | +| Free-phase variants (`UnitaryFreePhaseInfidelityObjective`, `KetFreePhase...`, `CoherentKetFreePhase...`) | Gate infidelity over the computational subspace **up to free per-component virtual-Z phases** — objective-only; the frame is exact software, never a cheat | `objectives.jl`; the free-phase-decisiveness convention | +| `DensityMatrixInfidelityObjective` | $1 - \mathrm{tr}(\rho_\text{goal}\rho_N)$ (Hilbert–Schmidt) | `objectives.jl` | +| `LeakageObjective` | $\lVert(I - \Pi)\psi_k\rVert^2$ at knot points | `objectives.jl` | +| `QuadraticRegularizer` | $R = \sum_k r\lVert\mathbf{a}_k\rVert^2$ | `docs_cache.jl` usage | +| Minimum-time term | $D\sum_k \Delta t_k$ | Paper Eq. (22) continuation | +| Piccolissimo regularizers (`HermiteBendingEnergyRegularizer`, `HermiteC2Regularizer`) | Bending energy $\int\lvert u''\rvert^2$ and C² forms on the spline | `objectives/hermite_bending_energy_regularizer.jl` | +| Adjoint robustness / `UnitarySensitivityObjective` | First-order infidelity response $\lvert\partial J/\partial\theta\rvert$ to Hamiltonian-parameter perturbations, via adjoints (no finite differences) | `objectives_robustness/`; Piccolissimo reexports | +| Channel process infidelity (Piccolissimo) | $F_\mathrm{pro}$ exact vs Choi, linear coordinates + HS-dual $\tau_k$ | `objectives/channel_process_infidelity.jl` | +| `MeasurementMatchingObjective` (Intonatissimo) | $\lVert y(z) - y_\text{target}\rVert^2_{Q_\text{meas}}$ | The intonatissimo card's display section | + +### Constraints + +| Constraint | Classic form | Where it lives | +| --- | --- | --- | +| Control amplitude bounds | $\lvert a_i(t)\rvert \le a_{i,\max}$ | The z-vector's bound layer (never a nonlinear row) | +| Slew / interior overshoot (`CubicSplineBoundConstraint`, `du_bound`) | The **emitted spline** stays within bounds between knots — the knot values alone do not guarantee it | `constraints_spline/`; the knots-vs-emitted distinction is load-bearing | +| Leakage bound (`KnotPointConstraint` / `NonlinearKnotPointConstraint`) | $\lVert(I-\Pi)\psi(t_k)\rVert^2 \le \varepsilon$ at knots | `constraints.jl` | +| `FinalUnitaryFidelityConstraint` | $\mathcal{F}(U_N) \ge \bar{\mathcal{F}}$ | `templates/minimum_time_problem.jl` | +| `DurationConstraint` | $\sum_k \Delta t_k \le T_{\max}$ (or per-interval $\Delta t$ bounds) | `templates/`; per-interval floors encode physics (settling, clock) | +| QILC trust region | $\lVert u - u_\text{ref}\rVert^2_{R_\text{tr}}$ recentered on $z_\text{ref}$ each iteration | The intonatissimo card | + +### Solvers + +| Solver | What it is | When stated | +| --- | --- | --- | +| IPOPT (via DirectTrajOpt) | Interior-point, exact sparse NLP | Every Piccolo solve; `exact_hessian=false` on MultiKet is a recorded caveat | +| Altissimo (Piccolissimo) | Matrix-free interior-point; callback fields $f_\text{val}$, $eq\_viol$, $kkt\_error$ are the honest convergence line | Piccolissimo solves | +| Gauss–Newton second-order mode | Matrix-free HVP composition | When Piccolissimo's GN default runs | +| Armijo backtracking (ILC) | Accept/reject on objective descent; show accepted **and** rejected steps | QILC convergence displays | + +**Completeness rule.** A formulation statement that omits any row the +problem actually used is incomplete — the reader must be able to +reconstruct the NLP from the statement plus the run dir. When a component +is not in these tables (a new upstream constructor, an inline constraint), +state its form from source and mark it `unverified` until smoked. + ## Where these render - **PI-report findings** (`pi-report` fill contract, amicode #1700): a From 8f92ee77a1e01887525a524571fa12908330bb23 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 4 Oct 2026 08:14:01 -0400 Subject: [PATCH 24/80] =?UTF-8?q?docs(skills):=20the=20papers=20are=20styl?= =?UTF-8?q?e=20exemplars,=20not=20sources=20of=20truth=20=E2=80=94=20the?= =?UTF-8?q?=20package=20source=20is=20the=20truth=20about=20what=20runs=20?= =?UTF-8?q?(#1705)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/formulation-display/SKILL.md | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/extension/skills/formulation-display/SKILL.md b/packages/extension/skills/formulation-display/SKILL.md index 411fd267f..87f0f2aec 100644 --- a/packages/extension/skills/formulation-display/SKILL.md +++ b/packages/extension/skills/formulation-display/SKILL.md @@ -1,6 +1,6 @@ --- name: formulation-display -description: Present an optimization problem in classic form — the papers' notation (Direct Collocation for Quantum Optimal Control, arXiv:2305.03261; Universal Dynamics with Globally Controlled Analog Quantum Simulators, arXiv:2508.19075): continuous core, direct collocation, indirect/GRAPE contrast, free-time/min-time, robustness ensembles, plus the per-solve display set. Use when writing a problem statement for a PI-report finding, demo, paper, or spec, or when a report/paper must show what was actually solved. +description: Present an optimization problem in classic form — objective, constraints, dynamics written out the way QOC trajectory-optimization papers do (style exemplars: Direct Collocation for Quantum Optimal Control, arXiv:2305.03261; Universal Dynamics with Globally Controlled Analog Quantum Simulators, arXiv:2508.19075): continuous core, direct collocation, indirect/GRAPE contrast, free-time/min-time, robustness ensembles, the full component inventory, plus the per-solve display set. Use when writing a problem statement for a PI-report finding, demo, paper, or spec, or when a report/paper must show what was actually solved. agents: [researcher, experimenter, engineer] surface: public scenarios: [pi-report-findings, demo-writeup, paper-section, spec-cards] @@ -8,15 +8,18 @@ scenarios: [pi-report-findings, demo-writeup, paper-section, spec-cards] # Formulation display — the problem in classic optimization form -House style, from the two papers: whenever a solve is described beyond a -headline number, the optimization problem itself is **written out** — -objective, constraints, dynamics — in the papers' notation. The -direct-collocation paper (arXiv:2305.03261) is the canonical source for the -DIRCOL forms; the global-control paper's Appendix G (arXiv:2508.19075) is -the canonical source for the continuous core and the direct/indirect -contrast. Equation pointers below cite those sources. +Whenever a solve is described beyond a headline number, the optimization +problem itself is **written out** — objective, constraints, dynamics — in +the classic minimize/subject-to layout that QOC papers use. The two +papers named above are **style exemplars, not sources of truth**: the +source of truth for *what was solved* is always the package source (the +inventory below maps API to math, every name verified there); the papers +exemplify *how to write it*. Write the form the package actually solves, +in the classic layout — do not copy a paper's equation where the package +differs. -The LaTeX idiom for all forms (the papers' `split` layout): +The LaTeX idiom for all forms (the classic `split` layout the exemplars +use): ```latex \begin{split} @@ -29,8 +32,9 @@ The LaTeX idiom for all forms (the papers' `split` layout): ## Form 1 — the continuous core -Source: arXiv:2508.19075, App. G, Eq. (111). Over time-dependent state and -control trajectories: +The parent problem every discrete form below discretizes (style exemplar: +arXiv:2508.19075, App. G; the affine Hamiltonian layout: arXiv:2305.03261, +Eq. (2)). Over time-dependent state and control trajectories: $$\min_{x(t),u(t)} \int_0^T \ell(x(t),u(t))\,dt + \ell_T(x(T)) \quad \text{s.t.} \quad \dot{x}(t) = f(x(t),u(t),t),\; x(0)=x_\mathrm{init}$$ @@ -46,13 +50,14 @@ below. ## Form 2 — direct collocation (the house default) -Source: arXiv:2305.03261, Eqs. (9), (10), (11). States and controls are -decision variables jointly; dynamics are enforced as **equality constraints** -between knots: +The method Piccolo solves (introduced in arXiv:2305.03261, Eqs. (9)–(11) — +the paper is the method's provenance, the package source is the truth about +what runs). States and controls are decision variables jointly; dynamics +are enforced as **equality constraints** between knots: $$\begin{split} \underset{z_{1:N}}{\text{minimize}}\quad & J(z_{1:N}) \\ -\text{subject to}\quad & f(z_k, z_{k+1}) = 0 \quad \text{(collocation, e.g. Pad\'e-integrator: } U_{k+1} - e^{-iH(\mathbf{a}_k)\Delta t}U_k\text{)} \\ +\text{subject to}\quad & f(z_k, z_{k+1}) = 0 \quad \text{(collocation, e.g. exponential step: } U_{k+1} = e^{-iH(\mathbf{a}_k)\Delta t}U_k\text{)} \\ & U_1 = I_{2n} \\ & |\mathbf{a}_k| \le \mathbf{a}_{\max} \end{split}$$ @@ -73,8 +78,8 @@ finding that formulates a new problem. ## Form 3 — indirect (GRAPE) contrast -Source: arXiv:2508.19075, Eqs. (114)–(115). Optimize over controls only; -states are retrieved by propagation: +The contrast form (style exemplar: arXiv:2508.19075, Eqs. (114)–(115)). +Optimize over controls only; states are retrieved by propagation: $$\underset{u_{1:N-1}}{\text{minimize}} \sum_{k=1}^{N-1} \ell(x_k(u_{1:k-1}),u_k) + \ell_T(x_N) \quad \text{s.t.} \quad |u(t)| \le u_{\max}$$ @@ -87,7 +92,9 @@ do the same, not present it as a competing option we run. ## Form 4 — free-time / minimum-time -Source: arXiv:2305.03261, Eq. (22) and its warm-start continuation. Stage +The two-stage construction the min-time toolchain runs (method provenance: +arXiv:2305.03261, Eq. (22) and its warm-start continuation; what runs is +`MinimumTimeProblem` per the package). Stage one solves a free-time problem (knot times $\Delta t_k$ are decision variables) for a regularized objective: @@ -154,13 +161,13 @@ forms follow the papers' notation. | Term | Classic form | Source | | --- | --- | --- | -| `UnitaryInfidelityObjective` | $\ell(U) = 1 - \frac{1}{n}\lvert\mathrm{tr}(U_\text{goal}^\dagger U)\rvert$ | Paper Eq. (3), arXiv:2305.03261 | +| `UnitaryInfidelityObjective` | $\ell(U) = 1 - \frac{1}{n}\lvert\mathrm{tr}(U_\text{goal}^\dagger U)\rvert$ | `objectives.jl` (the implemented loss; the same form appears in the style exemplar, arXiv:2305.03261 Eq. (3)) | | `KetInfidelityObjective` | $1 - \lvert\langle\psi_\text{goal}\vert\psi_N\rangle^2\rvert$ | `objectives.jl` | | Free-phase variants (`UnitaryFreePhaseInfidelityObjective`, `KetFreePhase...`, `CoherentKetFreePhase...`) | Gate infidelity over the computational subspace **up to free per-component virtual-Z phases** — objective-only; the frame is exact software, never a cheat | `objectives.jl`; the free-phase-decisiveness convention | | `DensityMatrixInfidelityObjective` | $1 - \mathrm{tr}(\rho_\text{goal}\rho_N)$ (Hilbert–Schmidt) | `objectives.jl` | | `LeakageObjective` | $\lVert(I - \Pi)\psi_k\rVert^2$ at knot points | `objectives.jl` | | `QuadraticRegularizer` | $R = \sum_k r\lVert\mathbf{a}_k\rVert^2$ | `docs_cache.jl` usage | -| Minimum-time term | $D\sum_k \Delta t_k$ | Paper Eq. (22) continuation | +| Minimum-time term | $D\sum_k \Delta t_k$ | `templates/minimum_time_problem.jl`; the two-stage construction follows arXiv:2305.03261's continuation | | Piccolissimo regularizers (`HermiteBendingEnergyRegularizer`, `HermiteC2Regularizer`) | Bending energy $\int\lvert u''\rvert^2$ and C² forms on the spline | `objectives/hermite_bending_energy_regularizer.jl` | | Adjoint robustness / `UnitarySensitivityObjective` | First-order infidelity response $\lvert\partial J/\partial\theta\rvert$ to Hamiltonian-parameter perturbations, via adjoints (no finite differences) | `objectives_robustness/`; Piccolissimo reexports | | Channel process infidelity (Piccolissimo) | $F_\mathrm{pro}$ exact vs Choi, linear coordinates + HS-dual $\tau_k$ | `objectives/channel_process_infidelity.jl` | From 4cac10b6305e75f4cc87c484a3feb40281dad392 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 4 Oct 2026 17:39:13 -0400 Subject: [PATCH 25/80] =?UTF-8?q?docs(skills):=20formulation-display=20gai?= =?UTF-8?q?ns=20the=20record=20doctrine=20=E2=80=94=20render=20from=20form?= =?UTF-8?q?ulation.toml,=20the=20papers'=20register,=20provenance=20rules,?= =?UTF-8?q?=20receipt=20meanings=20(#1705)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/formulation-display/SKILL.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/extension/skills/formulation-display/SKILL.md b/packages/extension/skills/formulation-display/SKILL.md index 87f0f2aec..4953e52ee 100644 --- a/packages/extension/skills/formulation-display/SKILL.md +++ b/packages/extension/skills/formulation-display/SKILL.md @@ -210,6 +210,45 @@ state its form from source and mark it `unverified` until smoked. - **Spec cards**: the falsifiable acceptance entries pair with the formal statement — state the problem, then the gates that adjudicate it. +## The record is the truth — render from `formulation.toml` + +These forms are **generated from the run's `formulation.toml`**, never +hand-written from memory of the script (the formulation-record contract, +amicode #1700/#1710). The renderer (`scripts/pi-report/render_formulation.py`) +turns the record into the classic-form block; the agent writes the explanation +*around* it — zero agent invention inside the math. Corrections target the +record's source (the spec or the script) and re-render; a correction made in +rendered prose is lost on the next render. + +**The papers' register** (the two exemplar papers, Hong-ye appendix G): ONE +clean display equation carrying symbols only — no `\text{}` sentences, no +identifiers, no weights inside the math — then a single "Here, ..." prose +paragraph carrying every detail: weights, integrator + algorithm, $N$, +bounds, free-phase/free-time flags, solver. The equation states the shape; +the prose carries the numbers. + +**Provenance rules the render obeys:** + +- `canonical` is **derived upstream** (retained spec, verified against the + object) — a kwargs-built problem renders as a *Non-canonical record* + (best-effort extraction) with its call-site `template_actuals` and + `solver_actuals` labeled as actuals, never as verified round-trips. +- Call-site actuals **beat template-name inference**: a `SplinePulseProblem` + running PWC collocation (`integrator_type = "pwc"` in `template_actuals`) + renders piecewise-constant — never "spline-faithful" (the #540 case). +- Raw matrices are capped to digest + dims (emission caps them in the record; + the render passes the capped marker through) — full matrices live in the + spec/script source, never in synced run dirs. +- Receipt meanings: **UNBACKED** — no record in the run dir; any stated + formulation is unbacked. **PARTIAL-RECORD** — the record is missing + inventory rows (schema fields, objective weights, integrator, solver + actuals); fix the emission or the upstream extraction, not the render. + Unmapped record sections are preserved and named, never fatal. + +The fill contract (`../../../scripts/pi-report/FILL-CONTRACT.md` relative +to this skill's directory, in the extension's pi-report scripts) carries the +full report-side doctrine; this section governs the forms wherever they render. + ## Honesty bars (inherit `display-conventions`) - Legs named, never merged; the researcher's notation; provenance stamps. From 4b1b72577b651b5fd3de71686d535a7966789da0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 5 Oct 2026 10:14:24 -0400 Subject: [PATCH 26/80] docs(skills): formulation-display adopts the research loop report naming (#1718) --- packages/extension/skills/formulation-display/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/skills/formulation-display/SKILL.md b/packages/extension/skills/formulation-display/SKILL.md index 4953e52ee..d0e9d7e45 100644 --- a/packages/extension/skills/formulation-display/SKILL.md +++ b/packages/extension/skills/formulation-display/SKILL.md @@ -1,6 +1,6 @@ --- name: formulation-display -description: Present an optimization problem in classic form — objective, constraints, dynamics written out the way QOC trajectory-optimization papers do (style exemplars: Direct Collocation for Quantum Optimal Control, arXiv:2305.03261; Universal Dynamics with Globally Controlled Analog Quantum Simulators, arXiv:2508.19075): continuous core, direct collocation, indirect/GRAPE contrast, free-time/min-time, robustness ensembles, the full component inventory, plus the per-solve display set. Use when writing a problem statement for a PI-report finding, demo, paper, or spec, or when a report/paper must show what was actually solved. +description: Present an optimization problem in classic form — objective, constraints, dynamics written out the way QOC trajectory-optimization papers do (style exemplars: Direct Collocation for Quantum Optimal Control, arXiv:2305.03261; Universal Dynamics with Globally Controlled Analog Quantum Simulators, arXiv:2508.19075): continuous core, direct collocation, indirect/GRAPE contrast, free-time/min-time, robustness ensembles, the full component inventory, plus the per-solve display set. Use when writing a problem statement for a research loop report finding, demo, paper, or spec, or when a report/paper must show what was actually solved. agents: [researcher, experimenter, engineer] surface: public scenarios: [pi-report-findings, demo-writeup, paper-section, spec-cards] @@ -201,7 +201,7 @@ state its form from source and mark it `unverified` until smoked. ## Where these render -- **PI-report findings** (`pi-report` fill contract, amicode #1700): a +- **Research loop report findings** (the fill contract in the extension's pi-report scripts, amicode #1700): a finding that formulates a new problem carries its classic form inline; findings that reuse an established formulation name it and cite the spec. - **Demo/paper write-ups**: the form opens the methods section; the From 7c8d3e7dcbd952ecfb77cc90feae056e8bbb8284 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 5 Oct 2026 12:50:34 -0400 Subject: [PATCH 27/80] =?UTF-8?q?feat(app):=20Cmd+F=20find=20in=20Preview?= =?UTF-8?q?=20PDFs=20(S1)=20=E2=80=94=20shared=20DOM=20engine,=20Custom=20?= =?UTF-8?q?Highlight=20painting,=20focus=20routing=20+=20hijack=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app-bundle/manifest.json | 26 +- .../components/session/pdf-canvas-view.tsx | 13 +- .../components/session/pdf-find-controller.ts | 186 ++++++++++++ .../components/session/pdf-find-core.test.ts | 285 ++++++++++++++++++ .../src/components/session/pdf-find-core.ts | 275 +++++++++++++++++ .../src/components/session/pdf-find-guard.ts | 52 ++++ .../components/session/pdf-find-highlight.ts | 85 ++++++ .../src/components/session/pdf-find-pill.tsx | 106 +++++++ .../components/session/preview-file-view.tsx | 142 ++++++++- .../session/session-preview-tab.tsx | 69 +++++ .../overlay/packages/app/src/entry.tsx | 4 + 11 files changed, 1231 insertions(+), 12 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-controller.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-guard.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-highlight.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-pill.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 8e65c7f34..1f327d031 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -27,7 +27,7 @@ "packages/app/src/app.tsx": "07b292f88de59fad23b0e7564e0c536214917b65b50326552894925a9c0e17d1", "packages/app/src/design-polish.css": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910", "packages/app/src/desktop-menu.ts": "350e0e9f2e4294bfe1b1f357b3cd2ea0908b14d20ca1e79fda2305db1958fb24", - "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", + "packages/app/src/entry.tsx": "080b3c11cdd61dfaf4bea8132970a1a300b77b0e8fc53065e42d5ccd75eaed01", "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", "packages/app/test-browser/empty-workspace-landing.test.ts": "83d19ecf5da37c234dbde1a004432865204d7f412a335c4e470a07e73d9bcda4", @@ -626,17 +626,23 @@ "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", "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": "7b221de755b851170e4b3dfc4d78d8446f6dfa40116b7529466db24e714d90f5", + "packages/app/src/components/session/preview-file-view.tsx": "038ff5687507e8c5450d5862211a8fbd94f3cd7137d3c7e2043ecd697bdeaf12", "packages/app/src/components/session/session-chats-dropdown.test.ts": "2003d2a15781337ea6bff2c68e730cc5b6df38697f15d927ab26913936444db7", "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", "packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe", + "packages/app/src/components/session/session-preview-tab.tsx": "baaf8bfd25e481c59fba1b84aaefc3e92923b1360a7561e390c2175da7d52753", "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", @@ -873,10 +879,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-24T04:46:38.771Z", + "extracted_at": "2026-10-05T16:28:00.091Z", "per_package": { "packages/app": { - "A": 186, + "A": 192, "M": 157, "D": 0 }, @@ -922,7 +928,7 @@ } }, "counts": { - "overlay_total": 851, + "overlay_total": 857, "deletions": 7, "server_coupled": 0 }, @@ -1546,6 +1552,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", diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx index 878cec49f..2bcb927db 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx @@ -58,6 +58,10 @@ interface PdfCanvasViewProps { initialScrollTop?: number /** Reports page/scroll changes for persistence. */ onViewStateChange?: (state: { page: number; scrollTop: number }) => void + /** Called once with the wrapper div (the find index root), and again with + * null on unmount. Lets the per-pane find controller build its text-layer + * index without owning the viewer. */ + onRootChange?: (root: HTMLDivElement | null) => void } type PdfPageNavigation = { @@ -529,6 +533,7 @@ export function PdfCanvasView(props: PdfCanvasViewProps) { if (doc) doc.cleanup() if (navigationFrame !== undefined) cancelAnimationFrame(navigationFrame) props.onPageNavigationChange?.(null) + props.onRootChange?.(null) }) createEffect(() => { @@ -601,7 +606,13 @@ export function PdfCanvasView(props: PdfCanvasViewProps) { } return ( -

+
{ + wrapperRef = el + props.onRootChange?.(el ?? null) + }} + class="inline-flex flex-col items-center gap-3 p-4 min-w-full min-h-full" + > {textAvailabilityMessage() && ( diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-controller.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-controller.ts new file mode 100644 index 000000000..9755ab3bf --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-controller.ts @@ -0,0 +1,186 @@ +/** + * pdf-find-controller — per-pane find state for a Preview PDF pane (S1). + * + * One controller per PreviewFileView instance. Owns the reactive state + * { open, query, matches, currentIndex, hasText } and the wiring between the + * find pill, the CSS Custom Highlight painter, and the pane's scroll + * container. + * + * The searchable index is built LAZILY on first open (the PDF text layer is + * main-thread pdf.js work — don't pay it for panes that never search) and + * rebuilt whenever the pane content signal changes (recompile, tab restore) + * — the caller wires `rebuild` to the base64 prop. + * + * Scroll-to-match never uses scrollIntoView (it would scroll ancestors — + * the workspace grid, the side panel). It computes the current match's rect + * relative to the pane's own [data-preview-scroll] container and sets only + * that container's scrollTop/scrollLeft, landing the match ~50px below the + * floating controls. + * + * @module + */ + +import { createSignal } from "solid-js" +import { buildPdfIndex, findMatches, type PdfIndex, type PdfMatch } from "./pdf-find-core" +import { clearPdfFindHighlights, paintPdfFindHighlights } from "./pdf-find-highlight" + +const QUERY_DEBOUNCE_MS = 150 +/** Top margin so the match sits below the floating controls pill. */ +const SCROLL_TOP_MARGIN_PX = 50 + +export interface PdfFindController { + open: () => boolean + query: () => string + matches: () => PdfMatch[] + currentIndex: () => number + /** True once an index build found real text; false → the pill shows the + * "No searchable text in this PDF" message instead of a counter. */ + hasText: () => boolean + count: () => number + openFind: () => void + close: () => void + setQuery: (value: string) => void + next: () => void + prev: () => void + clearHighlights: () => void + /** Drop the cached index (content changed) and re-run the active query. */ + rebuild: () => void + dispose: () => void +} + +export function createPdfFindController(opts: { + /** The PdfCanvasView wrapper div — the index root. */ + pdfRoot: () => HTMLElement | undefined + /** The pane's own scroll container ([data-preview-scroll]). */ + scrollContainer: () => HTMLElement | undefined +}): PdfFindController { + const [open, setOpen] = createSignal(false) + const [query, setQuerySignal] = createSignal("") + const [matches, setMatches] = createSignal([]) + const [currentIndex, setCurrentIndex] = createSignal(0) + const [hasText, setHasText] = createSignal(true) + + let index: PdfIndex | null = null + let debounce: ReturnType | undefined + + const ensureIndex = (): PdfIndex => { + if (index) return index + const root = opts.pdfRoot() + index = root ? buildPdfIndex(root) : { text: "", steps: [], hasText: false } + setHasText(index.hasText) + return index + } + + const paint = () => { + paintPdfFindHighlights( + matches().map((m) => m.range), + currentIndex(), + ) + } + + const scrollToCurrent = () => { + const match = matches()[currentIndex()] + const scroll = opts.scrollContainer() + if (!match || !scroll) return + // Re-measure live — the cached rect is from build time and the user may + // have scrolled or zoomed since. + const rect = match.range.getBoundingClientRect() + const host = scroll.getBoundingClientRect() + // Vertical: land the match just below the floating controls. + const targetTop = scroll.scrollTop + (rect.top - host.top) - SCROLL_TOP_MARGIN_PX + // Horizontal: only scroll when the match is outside the visible band — + // never recenter a match the user can already see. + const matchLeft = rect.left - host.left + const matchRight = rect.right - host.right + let targetLeft = scroll.scrollLeft + if (matchLeft < 0) { + targetLeft = scroll.scrollLeft + matchLeft - 8 + } else if (matchRight > 0) { + targetLeft = scroll.scrollLeft + matchRight + 8 + } + scroll.scrollTop = Math.max(0, targetTop) + scroll.scrollLeft = Math.max(0, targetLeft) + } + + const run = (args?: { reset?: boolean; scroll?: boolean }) => { + const value = query().trim() + if (!value) { + setMatches([]) + setCurrentIndex(0) + clearPdfFindHighlights() + return + } + const idx = ensureIndex() + if (!idx.hasText) { + setMatches([]) + setCurrentIndex(0) + clearPdfFindHighlights() + return + } + const found = findMatches(idx, value, { caseSensitive: false, wholeWord: false }) + setMatches(found) + const desired = args?.reset ? 0 : currentIndex() + setCurrentIndex(found.length ? Math.min(desired, found.length - 1) : 0) + paint() + if (args?.scroll && found.length > 0) scrollToCurrent() + } + + const step = (dir: 1 | -1) => { + const found = matches() + if (!open() || found.length === 0) return + setCurrentIndex((currentIndex() + dir + found.length) % found.length) + paint() + scrollToCurrent() + } + + return { + open, + query, + matches, + currentIndex, + hasText, + count: () => matches().length, + + openFind: () => { + if (open()) return + setOpen(true) + ensureIndex() + run({ reset: true, scroll: true }) + }, + + close: () => { + setOpen(false) + setQuerySignal("") + setMatches([]) + setCurrentIndex(0) + clearPdfFindHighlights() + }, + + setQuery: (value: string) => { + setQuerySignal(value) + setCurrentIndex(0) + if (debounce) clearTimeout(debounce) + debounce = setTimeout(() => { + debounce = undefined + run({ reset: true, scroll: true }) + }, QUERY_DEBOUNCE_MS) + }, + + next: () => step(1), + prev: () => step(-1), + + clearHighlights: () => clearPdfFindHighlights(), + + rebuild: () => { + index = null + setHasText(true) + if (!open()) return + run({ reset: true, scroll: false }) + }, + + dispose: () => { + if (debounce) clearTimeout(debounce) + clearPdfFindHighlights() + }, + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.test.ts new file mode 100644 index 000000000..8ad558682 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test } from "bun:test" +import { buildPdfIndex, findMatches } from "./pdf-find-core" +import { isPdfFindTarget } from "./pdf-find-guard" + +// --------------------------------------------------------------------------- +// DOM builders — mimic pdf.js's text-layer output +// --------------------------------------------------------------------------- + +function textLayer(page: number, parts: Array): HTMLDivElement { + const layer = document.createElement("div") + layer.setAttribute("data-pdf-text-layer", "") + layer.dataset.page = String(page) + for (const part of parts) { + if (part === "br") { + layer.appendChild(document.createElement("br")) + continue + } + const span = document.createElement("span") + span.textContent = part + layer.appendChild(span) + } + return layer +} + +function pdfRoot(...layers: HTMLDivElement[]): HTMLElement { + const root = document.createElement("div") + for (const layer of layers) root.appendChild(layer) + return root +} + +// --------------------------------------------------------------------------- +// buildPdfIndex +// --------------------------------------------------------------------------- + +describe("buildPdfIndex", () => { + test("extracts plain text from a single page", () => { + const root = pdfRoot(textLayer(1, ["hello world"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("hello world") + expect(index.hasText).toBe(true) + }) + + test("joins multiple pages into one string with a space between", () => { + const root = pdfRoot(textLayer(1, ["page one"]), textLayer(2, ["page two"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("page one page two") + }) + + test("collapses whitespace runs to a single space", () => { + const root = pdfRoot(textLayer(1, ["hello \t\n world"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("hello world") + }) + + test("a
is a line break — emits a space", () => { + const root = pdfRoot(textLayer(1, ["line one", "br", "line two"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("line one line two") + }) + + test("a line ending in - joins the next line WITHOUT the hyphen", () => { + const root = pdfRoot(textLayer(1, ["trans-", "br", "port"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("transport") + }) + + test("hyphen join: offsets after the removed hyphen still land on real text", () => { + const root = pdfRoot(textLayer(1, ["trans-", "br", "port"])) + const index = buildPdfIndex(root) + // "transport" — 'p' of "port" is at normalized offset 5. It must map + // into the "port" text node, offset 0. + const step = index.steps.find((s) => s.node.textContent === "port") + expect(step).toBeDefined() + expect(step!.offset).toBe(0) + expect(step!.count).toBe(4) + }) + + test("empty text layer → hasText false", () => { + const root = pdfRoot(textLayer(1, [])) + const index = buildPdfIndex(root) + expect(index.text).toBe("") + expect(index.hasText).toBe(false) + }) + + test("whitespace-only text layer → hasText false", () => { + const root = pdfRoot(textLayer(1, [" ", " "])) + const index = buildPdfIndex(root) + expect(index.hasText).toBe(false) + }) + + test("NFKC-normalizes span text", () => { + // fi (U+FB01 LATIN SMALL LIGATURE FI) → "fi" + const root = pdfRoot(textLayer(1, ["fish"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("fish") + }) + + test("spans within a page are concatenated without added spaces", () => { + const root = pdfRoot(textLayer(1, ["hel", "lo"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("hello") + }) +}) + +// --------------------------------------------------------------------------- +// findMatches +// --------------------------------------------------------------------------- + +describe("findMatches", () => { + test("finds a single match with DOM endpoints", () => { + const root = pdfRoot(textLayer(1, ["hello world"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "world") + expect(matches).toHaveLength(1) + expect(matches[0].page).toBe(1) + expect(matches[0].start).toBe(6) + expect(matches[0].end).toBe(11) + expect(matches[0].startNode.textContent).toBe("hello world") + expect(matches[0].range.toString()).toBe("world") + }) + + test("an internal space keeps its own source (the whitespace char in the text node)", () => { + // Each normalized char maps to its own DOM char, so a match starting + // right after the space resolves to (node, offsetOfChar). + const root = pdfRoot(textLayer(1, ["hello world"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "world") + expect(matches).toHaveLength(1) + expect(matches[0].startOffset).toBe(6) + expect(matches[0].range.toString()).toBe("world") + }) + + test("case-insensitive by default", () => { + const root = pdfRoot(textLayer(1, ["Hello HELLO hello"])) + const index = buildPdfIndex(root) + expect(findMatches(index, "hello")).toHaveLength(3) + }) + + test("caseSensitive: true respects case", () => { + const root = pdfRoot(textLayer(1, ["Hello HELLO hello"])) + const index = buildPdfIndex(root) + expect(findMatches(index, "hello", { caseSensitive: true })).toHaveLength(1) + }) + + test("finds all occurrences, in document order", () => { + const root = pdfRoot(textLayer(1, ["a b a b a"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "a") + expect(matches).toHaveLength(3) + expect(matches.map((m) => m.start)).toEqual([0, 4, 8]) + }) + + test("a match spanning two spans gets a cross-node range", () => { + const root = pdfRoot(textLayer(1, ["hel", "lo world"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "hello") + expect(matches).toHaveLength(1) + expect(matches[0].range.toString()).toBe("hello") + expect(matches[0].startNode.textContent).toBe("hel") + expect(matches[0].endNode.textContent).toBe("lo world") + }) + + test("a match spanning a
line break matches across the space", () => { + const root = pdfRoot(textLayer(1, ["line one", "br", "line two"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "one line") + expect(matches).toHaveLength(1) + // The break-space maps to the char before the break ("e" of "one"), so + // the range covers "one" + the line-two prefix the space mapped to. + expect(matches[0].range.toString()).toBe("oneline") + expect(matches[0].startNode.textContent).toBe("line one") + expect(matches[0].endNode.textContent).toBe("line two") + }) + + test("hyphenated line break: 'transport' matches 'trans-\\nport'", () => { + const root = pdfRoot(textLayer(1, ["trans-", "br", "port"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "transport") + expect(matches).toHaveLength(1) + // The normalized string drops the hyphen, but the DOM range spans the + // real text — "trans-" + "port". + expect(matches[0].range.toString()).toBe("trans-port") + expect(matches[0].startNode.textContent).toBe("trans-") + expect(matches[0].endNode.textContent).toBe("port") + }) + + test("matches on page 2 are tagged page 2", () => { + const root = pdfRoot(textLayer(1, ["alpha"]), textLayer(2, ["beta alpha"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "alpha") + expect(matches).toHaveLength(2) + expect(matches[0].page).toBe(1) + expect(matches[1].page).toBe(2) + }) + + test("empty query returns no matches", () => { + const root = pdfRoot(textLayer(1, ["hello"])) + const index = buildPdfIndex(root) + expect(findMatches(index, "")).toHaveLength(0) + }) + + test("no match returns empty", () => { + const root = pdfRoot(textLayer(1, ["hello world"])) + const index = buildPdfIndex(root) + expect(findMatches(index, "zebra")).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// isPdfFindTarget (the hijack guard + command `when` predicate) +// --------------------------------------------------------------------------- + +describe("isPdfFindTarget", () => { + function previewPane(path: string, inner: HTMLElement): HTMLElement { + const host = document.createElement("div") + host.setAttribute("data-preview-host", path) + const scroll = document.createElement("div") + scroll.setAttribute("data-preview-scroll", "") + host.appendChild(scroll) + scroll.appendChild(inner) + document.body.appendChild(host) + return host + } + + test("true for a target inside a PDF pane's scroll container", () => { + const span = document.createElement("span") + const host = previewPane("docs/paper.pdf", span) + expect(isPdfFindTarget(span)).toBe(true) + host.remove() + }) + + test("true for the scroll container itself (focused pane)", () => { + const span = document.createElement("span") + const host = previewPane("paper.pdf", span) + const scroll = host.querySelector("[data-preview-scroll]")! + expect(isPdfFindTarget(scroll)).toBe(true) + host.remove() + }) + + test("false for a non-PDF preview pane", () => { + const span = document.createElement("span") + const host = previewPane("notes.md", span) + expect(isPdfFindTarget(span)).toBe(false) + host.remove() + }) + + test("false for a target outside any preview pane", () => { + const div = document.createElement("div") + document.body.appendChild(div) + expect(isPdfFindTarget(div)).toBe(false) + div.remove() + }) + + test("false for an input inside a PDF pane (editable owns its keys)", () => { + const input = document.createElement("input") + const host = previewPane("paper.pdf", input) + expect(isPdfFindTarget(input)).toBe(false) + host.remove() + }) + + test("false for a contenteditable inside a PDF pane", () => { + const editable = document.createElement("div") + editable.contentEditable = "true" + const host = previewPane("paper.pdf", editable) + expect(isPdfFindTarget(editable)).toBe(false) + host.remove() + }) + + test("false for a CodeMirror editor target even inside a PDF pane", () => { + const cm = document.createElement("div") + cm.classList.add("cm-editor") + const inner = document.createElement("div") + cm.appendChild(inner) + const host = previewPane("paper.pdf", cm) + expect(isPdfFindTarget(inner)).toBe(false) + host.remove() + }) + + test("case-insensitive .PDF extension", () => { + const span = document.createElement("span") + const host = previewPane("DOC.PDF", span) + expect(isPdfFindTarget(span)).toBe(true) + host.remove() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.ts new file mode 100644 index 000000000..e2780b955 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.ts @@ -0,0 +1,275 @@ +/** + * pdf-find-core — Pure find machinery for PDF panes (S1 of Cmd+F in Preview). + * + * Walks the pdf.js text layer ([data-pdf-text-layer] — styled by attribute, + * NOT the `.textLayer` class) under a PDF pane root and builds a normalized + * searchable string plus an offset map back into DOM (textNode, offset) + * pairs. `findMatches` scans that string and returns Ranges in document + * order, each tagged with its page number. + * + * Normalization: + * - NFKC (the text layer is already NFKC-normalized by pdf.js's + * streamTextContent, so ligatures are plain letters — this is a belt for + * the suspenders). + * - Whitespace runs collapse to a single space. + * - A
sibling is a line break → emits a space. + * - A line ending in "-" joins the next line WITHOUT the hyphen, so + * "transport" matches a hyphenated "trans-\nport". The removed hyphen's + * span characters become zero-width stops in the map so offsets after the + * break still land on real text. + * + * No UI, no CSS Custom Highlight API — painting lives in + * pdf-find-highlight.ts. Pure functions so the whole module is unit-testable + * away from the viewer. + * + * @module + */ + +/** One step of the offset map: `count` normalized characters come from + * (node, offset..offset+count). Steps never straddle a node boundary. */ +export interface PdfIndexStep { + node: Text + offset: number + count: number + page: number +} + +export interface PdfIndex { + /** The normalized searchable string. */ + text: string + steps: PdfIndexStep[] + /** True when at least one real (non-whitespace) text node was found. + * False → the caller reports "No searchable text in this PDF". */ + hasText: boolean +} + +export interface PdfMatch { + /** 1-based page number of the span the match STARTS in. */ + page: number + /** Normalized-string offsets [start, end). */ + start: number + end: number + /** DOM endpoints — enough to build a Range. */ + startNode: Text + startOffset: number + endNode: Text + endOffset: number + /** Rect of the range in viewport coordinates at build time (cache — the + * caller re-measures live before scrolling). */ + rect: { top: number; left: number; bottom: number; right: number } + range: Range +} + +// --------------------------------------------------------------------------- +// Index build +// --------------------------------------------------------------------------- + +/** + * Build the searchable index for the PDF rendered under `root` (the + * PdfCanvasView wrapper div). Safe to call before the text layer renders — + * returns an empty index (hasText: false), and the caller may rebuild later. + */ +export function buildPdfIndex(root: HTMLElement): PdfIndex { + const layers = Array.from(root.querySelectorAll("[data-pdf-text-layer]")) + + // Raw item stream: text nodes (each carrying its page) interleaved with + // line-break markers. A page boundary is a break too — pages never join + // into one continuous line. The text layer's spans are absolutely + // positioned, so DOM order inside each page container is the pdf.js + // content order. + type Item = { kind: "text"; node: Text; page: number } | { kind: "break" } + const items: Item[] = [] + layers.forEach((layer, layerIndex) => { + if (layerIndex > 0) items.push({ kind: "break" }) + const page = layerIndex + 1 + // Walk ALL nodes (elements + text) so
siblings register as breaks. + // A
is a line end; text nodes contribute their characters. We walk + // elements rather than reading text-node nextSibling because happy-dom + // (the test DOM) doesn't implement sibling pointers on Text. + const walker = document.createTreeWalker(layer, NodeFilter.SHOW_ALL) + let node = walker.nextNode() + while (node) { + if (node.nodeName === "BR") { + items.push({ kind: "break" }) + } else if (node instanceof Text && node.data.length > 0) { + items.push({ kind: "text", node, page }) + } + node = walker.nextNode() + } + }) + + // Raw char stream + per-char source. Breaks carry no source (null). + const raw: string[] = [] + const rawSource: Array<{ node: Text; offset: number; page: number } | null> = [] + for (const item of items) { + if (item.kind === "break") { + raw.push(" ") + rawSource.push(null) + continue + } + // NFKC per text node (ligatures arrive pre-split by pdf.js's + // streamTextContent — this is a belt for the suspenders). + const normalized = item.node.data.normalize("NFKC") + for (let i = 0; i < normalized.length; i++) { + raw.push(normalized[i]) + rawSource.push({ node: item.node, offset: i, page: item.page }) + } + } + + // Whitespace runs collapse to a single space kept in the run's first + // slot, mapped to ITS OWN source (the whitespace char in the text node, + // or — for a break — the char that precedes the break). Leading and + // trailing runs are dropped. A break-space whose preceding char is a + // hyphen is a line-wrap hyphen ("trans-\nport") — drop BOTH so + // "transport" matches; the removed hyphen keeps its slot but contributes + // no character, and a real range never needs to land on it because the + // break it paired with is gone too. + const n = raw.length + const out: string[] = new Array(n).fill("") + const outSource: Array<{ node: Text; offset: number; page: number } | null> = new Array(n).fill(null) + + // The source a break maps to: the char before it. For the first char of + // the stream there is none — the run is leading and gets dropped anyway. + const breakFallback = (slot: number) => (slot > 0 ? rawSource[slot - 1] : null) + + let i = 0 + // Drop leading whitespace runs entirely. + while (i < n && /\s/.test(raw[i])) i++ + let hasText = false + while (i < n) { + if (/\s/.test(raw[i])) { + // Find the end of the run. + let j = i + while (j < n && /\s/.test(raw[j])) j++ + if (j >= n) break // trailing run — dropped + // Hyphen-wrap check: the char before the run is a hyphen AND the run + // contains at least one break (a null-sourced slot — real PDFs wrap + // at line ends; a plain "word- word" space is NOT a wrap). + const prevIsHyphen = raw[i - 1] === "-" + const runHasBreak = rawSource.slice(i, j).some((s) => s === null) + if (prevIsHyphen && runHasBreak) { + // Drop the hyphen and the whole run — no space between the joined + // halves. + out[i - 1] = "" + outSource[i - 1] = null + } else { + // Collapse the run to one space in its first slot. The space maps + // to its own source — a break-space maps to the char before the + // break (breakFallback), a real whitespace char to itself. + out[i] = " " + outSource[i] = rawSource[i] ?? breakFallback(i) + } + i = j + continue + } + out[i] = raw[i] + outSource[i] = rawSource[i] + if (raw[i].trim().length > 0) hasText = true + i++ + } + + // Assemble the normalized text + run-length steps. + let text = "" + const steps: PdfIndexStep[] = [] + for (let k = 0; k < n; k++) { + const ch = out[k] + if (!ch) continue + text += ch + const src = outSource[k] + if (!src) continue + const last = steps[steps.length - 1] + if (last && last.node === src.node && last.offset + last.count === src.offset && last.page === src.page) { + last.count++ + } else { + steps.push({ node: src.node, offset: src.offset, count: 1, page: src.page }) + } + } + + return { text, steps, hasText } +} + +// --------------------------------------------------------------------------- +// Offset mapping +// --------------------------------------------------------------------------- + +/** Map a normalized-string offset to a DOM (textNode, offset) boundary. */ +function locate(steps: PdfIndexStep[], offset: number): { node: Text; offset: number } | null { + let base = 0 + for (const step of steps) { + const end = base + step.count + if (offset < end) return { node: step.node, offset: step.offset + (offset - base) } + if (offset === end) { + // A step boundary — return this step's end. Either side is a valid + // Range boundary; end-of-step plays best with client rects. + return { node: step.node, offset: step.offset + step.count } + } + base = end + } + const last = steps[steps.length - 1] + return last ? { node: last.node, offset: last.offset + last.count } : null +} + +/** Page of the span a normalized offset starts in (for the match counter + * and per-page tagging). */ +function pageAt(steps: PdfIndexStep[], offset: number): number { + let base = 0 + for (const step of steps) { + const end = base + step.count + if (offset < end) return step.page + base = end + } + return steps[steps.length - 1]?.page ?? 1 +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +export interface PdfFindOptions { + caseSensitive?: boolean + wholeWord?: boolean +} + +/** + * Find all matches of `query` in the index. Case-insensitive by default + * (S1); wholeWord is part of the signature but unused until S2. Returns + * matches in document order. Empty query → empty list. + */ +export function findMatches(index: PdfIndex, query: string, opts: PdfFindOptions = {}): PdfMatch[] { + const needleRaw = query.normalize("NFKC") + if (!needleRaw) return [] + const haystack = opts.caseSensitive ? index.text : index.text.toLowerCase() + const needle = opts.caseSensitive ? needleRaw : needleRaw.toLowerCase() + if (!needle) return [] + + const matches: PdfMatch[] = [] + let at = haystack.indexOf(needle) + while (at !== -1) { + const start = locate(index.steps, at) + const end = locate(index.steps, at + needle.length) + if (start && end) { + const range = document.createRange() + try { + range.setStart(start.node, start.offset) + range.setEnd(end.node, end.offset) + } catch { + at = haystack.indexOf(needle, at + needle.length) + continue + } + const bounds = range.getBoundingClientRect() + matches.push({ + page: pageAt(index.steps, at), + start: at, + end: at + needle.length, + startNode: start.node, + startOffset: start.offset, + endNode: end.node, + endOffset: end.offset, + rect: { top: bounds.top, left: bounds.left, bottom: bounds.bottom, right: bounds.right }, + range, + }) + } + at = haystack.indexOf(needle, at + needle.length) + } + return matches +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-guard.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-guard.ts new file mode 100644 index 000000000..8b8c381f5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-guard.ts @@ -0,0 +1,52 @@ +/** + * pdf-find-guard — boot-time shield for Cmd+F on Preview PDF panes. + * + * Upstream's file-find (session-ui/pierre/file-find.ts) installs a + * window-capture keydown listener the first time a `File` (diff/tool card) + * component mounts anywhere in the app. That listener calls + * preventDefault + stopPropagation on Cmd+F for non-editable targets, + * stealing the key from our Preview PDF panes. Its one escape hatch: it + * skips events where `event.defaultPrevented` is already true. + * + * This module installs a window-capture keydown listener at boot — before + * any `File` can mount — that, when the event target is inside a + * find-capable Preview PDF pane (a [data-preview-scroll] container under a + * [data-preview-host] whose file is a PDF), calls `event.preventDefault()` + * ONLY. Never stopPropagation: the command registry listens on + * document-capture and must still see the key to open the pane's find pill. + * + * @module + */ + +/** True when the event target is inside a find-capable Preview PDF pane. + * Editable fields (the find input, page input, zoom input) are NOT find + * targets — their Cmd+F reaches the pane via the registry's own fallback, + * and we never want to preventDefault a key an editable might own. */ +export function isPdfFindTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false + if (target instanceof HTMLElement) { + if (target.isContentEditable) return false + if (target.closest("[contenteditable='true']")) return false + if (target.closest(".cm-editor")) return false + if (target.closest("input, textarea, select")) return false + } + const scroll = target.closest("[data-preview-scroll]") + if (!scroll) return false + const host = scroll.closest("[data-preview-host]") + const path = host?.getAttribute("data-preview-host") ?? "" + return path.toLowerCase().endsWith(".pdf") +} + +export function installPdfFindGuard(win: Window = window): () => void { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return + if (!(event.metaKey || event.ctrlKey)) return + if (event.key.toLowerCase() !== "f") return + if (!isPdfFindTarget(event.target)) return + // preventDefault ONLY — file-find skips defaultPrevented events, and the + // command registry (document-capture) still fires to open the pill. + event.preventDefault() + } + win.addEventListener("keydown", onKeyDown, true) + return () => win.removeEventListener("keydown", onKeyDown, true) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-highlight.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-highlight.ts new file mode 100644 index 000000000..e828b55ef --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-highlight.ts @@ -0,0 +1,85 @@ +/** + * pdf-find-highlight — CSS Custom Highlight API painter for PDF find (S1). + * + * Paints matches via CSS.highlights under the "amicode-pdf-find*" names — + * upstream's file-find owns "opencode-find*" and deletes them globally, so + * those names are off-limits. The DOM (text layer, canvas) is NEVER touched. + * + * The PDF page is always white, so the colors are fixed and theme- + * independent: all-matches a translucent yellow, the current match a + * stronger orange. A small