Skip to content

Add Model Manager mini app by ocoomber - #4

Merged
MyPrototypeWhat merged 6 commits into
MiniMax-AI:mainfrom
ocoomber:add-openrouter-model-manager
Sep 23, 2026
Merged

MyPrototypeWhat merged 6 commits into
MiniMax-AI:mainfrom
ocoomber:add-openrouter-model-manager

Conversation

@ocoomber

@ocoomber ocoomber commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

New MiniApp: Model Manager (openrouter-model-manager)

A MiniApp for browsing, searching, and enabling/disabling the models in your MiniMax Code config.yaml — no more find-and-replace in a text editor.

What it does

  • Works with any provider (OpenRouter, custom providers, local endpoints like Ollama/LM Studio). Every model block with enabled: flags is picked up automatically and merged into one list grouped by model family — there is no provider-switching UI. (The plugin ID openrouter-model-manager is kept for stability, but the app is not OpenRouter-specific.)
  • Instant save per toggle; filter chips (All / Enabled / Disabled); bulk "Enable matching / Disable matching" scoped to the current search results; per-family enable/disable buttons.
  • One-level Undo with a stale-change guard: it refuses instead of overwriting if the file changed outside the app since the mutation. A multi-provider bulk action is reverted as one operation, not provider-by-provider.
  • Automatic timestamped backups in backups/ under the MiniApp's own data directory (per the runtime's "store durable state under dataDir" guidance), pruned to the newest 20 — nothing in the data-root namespace is touched.
  • Prominent restart reminder banner after any change — the host app must be restarted for config changes to take effect (a MiniApp cannot restart the host).
  • Collapsible model families grouped by ID prefix; a subtle green dot marks collapsed families that contain enabled models.
  • OpenRouter model page links only for OpenRouter providers, with a strict URL allowlist.
  • Atomic, line-based YAML editing: only the toggled model's enabled: line is rewritten. Each line's own terminator is preserved (mixed CRLF/LF/CR files stay intact), the original file mode is kept, concurrent mutations are serialized, and colon-tolerant keys (llama3.1:latest, :free) are supported.

Checklist

  • Complete plugin package under plugins/ocoomber/openrouter-model-manager/, including the hidden .minimax-plugin/ directory
  • Works standalone when copied to ~/.minimax/plugins/openrouter-model-manager/
  • English + Simplified Chinese READMEs with cross-links
  • MIT LICENSE
  • Entry added to the app tables in both root READMEs

File access, process spawning & network

  • Reads and writes only config.yaml. Resolution walks ancestors of the injected data directory looking for a config.yaml (the Host injects a plugin-owned subdirectory several levels below the data root, and the MiniMax Code config lives at the data root or one of its ancestors in the current Host layout), then falls back to the default ~/.minimax/config.yaml. The parent walk is an implementation detail of the current Host layout, not a guaranteed API — the default fallback is what protects non-default installs.
  • Writes go through a temp file .config.yaml.mm-tmp created next to the config, then renamed; the temp file is removed if anything fails.
  • No network requests from the app itself. The only OS-level action is opening https://openrouter.ai/<vendor>/<model> pages in the user's own browser via the OS opener (rundll32/cmd/explorer on Windows, open on macOS, xdg-open on Linux); anything outside the allowlist is rejected server-side.
  • Secrets: the server returns only model id / name / enabled / contextLimit to the UI. API key material is never read into the UI, returned by the API, or displayed.

Test environment & results

  • Windows 11 (build 10.0.26200), MiniMax Code 3.0.73, plugin v1.2.4.
  • Automated tests: tests/api-set.test.mjs, tests/parser.test.mjs, and tests/resolveConfigPath.test.mjs, 11 tests via node --test — including a real POST /api/set request that verifies a single-model toggle changes config.yaml, plus EOL round-trip, provider discovery, colon-in-id keys, commented lines, enabled-state extraction, multi-provider toggle invariants, and config-path resolution.
  • Manual end-to-end against a real ~5,900-line config (446 toggleable OpenRouter models): toggling, search/filters, bulk enable/disable, undo, backups, and the restart flow, verified in the running desktop app.
  • Unverified / known limits: no automated UI tests (rendered-page visuals confirmed manually by the author); macOS/Linux code paths are untested — feedback welcome.

A MiniApp for browsing, searching, and enabling/disabling models in ~/.minimax/config.yaml directly from MiniMax Code.

- Works with any provider (OpenRouter, custom, local endpoints)
- Instant save per toggle; bulk enable/disable with one-level undo
- Automatic timestamped backups before bulk changes
- Atomic, line-based YAML editing preserving indentation and line endings
- Restart reminder banner; OpenRouter model page links
- English and Simplified Chinese READMEs; MIT licensed
The UI navigates by model family (ID prefix) only. All manageable model blocks in config.yaml are merged into a single family-grouped list; blocks without enabled flags (e.g. the built-in always-on provider) are skipped as before.
@MyPrototypeWhat

Copy link
Copy Markdown
Collaborator

Thanks for the contribution — this is the first Mini App in the repo that writes to the host config, and the line-based approach that preserves indentation and unknown keys is a sensible choice.

Package-level checks all pass: .minimax-plugin/plugin.json, package.json#mcode and miniapp/miniapp.json validate against the host's strict readers, miniapp-api.ts is byte-identical to the canonical copy, the icon is a real 128×128 PNG, the plugin ID is unique, and both root READMEs are updated. So structurally this is a valid Mini App package.

Because this plugin modifies config.yaml (which holds the user's API keys), we're applying a slightly higher bar than for the read-only dashboards already in the repo. Findings are grouped by what we need before merging vs. what is optional.

1. Required before merge (CONTRIBUTING / disclosure)

a. README is missing the tested environment. CONTRIBUTING #3 asks each README to state tested client versions and operating systems. The PR description has "Windows 10.0.26200 x64, MiniMax Code Desktop, plugin v1.2.2" — please add that (with the MiniMax Code version) to both READMEs, and note that macOS/Linux are untested if that's the case.

b. File access / process disclosure is incomplete. The Privacy section says the app "reads and writes only your ~/.minimax/config.yaml", but:

  • /api/open-external spawns OS processes (rundll32 / cmd / explorer on Windows, open on macOS, xdg-open on Linux) — server.mjs L215-250.
  • Writes go through a temp file .config.yaml.mm-tmp created next to config.yaml — L30, L133.

Both are fine, but the repo's model is "users read the README to decide what to install", so please list them.

c. Two statements in the PR description have no counterpart in the diff.

  • "secrets render as [SECRET:<key-name>] placeholders" — there's no such code. The server only returns id / name / enabled / contextLimit, which is actually the better property; please describe it that way instead.
  • "24/24 parser tests passed" — no test files are in the PR. splitConfigText and parseProviders are exported (presumably for tests) but nothing consumes them. Either include the tests (outside miniapp/ so they don't become part of the runtime payload) or rephrase as local-only, unpublished testing.

d. Hardcoded ~/.minimax/config.yaml (server.mjs L15). The root README tells users "If you have configured a different data directory, use that directory instead" — with a non-default data directory or a profile, this plugin will read/write the wrong file. The existing plugins/amszuidas/mcode-token-usage-board/miniapp/node/server.mjs (L22-36) shows the pattern: walk up from context.dataDir to the data root and fall back to ~/.minimax only if nothing is found. At minimum, please state in the README that only the default data directory is supported.

2. Please fix or disclose (data safety on config.yaml)

a. Atomic write drops the original file mode. writeFile(tmp) + rename (L29-33, L133-135) replaces config.yaml with a file created under the default umask — if the user's config was 0600, it silently becomes 0644. Suggest const { mode } = await stat(CONFIG_PATH) and writeFile(tmp, data, { mode }), and remove the temp file if rename fails.

b. Lost writes with multiple providers. The client fires one POST /api/bulk per provider in parallel (index.html L553-563); each handler does a full-file read → modify → write (server.mjs L164-181), so the last writer wins and earlier providers' changes are dropped. lastSnapshot is overwritten the same way, so Undo can't recover it. Suggest a single request carrying all provider groups, or a server-side promise queue that serializes mutations.

c. The line-ending claim doesn't hold for mixed EOLs. splitConfigText (L22-26) picks one EOL for the whole file, so a file with mixed CRLF/LF is normalized on the first write — that contradicts "only the toggled model's enabled: line is rewritten" and "line endings … preserved". Either preserve per-line EOL (e.g. split with a capturing regex and re-join per line) or soften the README/PR wording.

d. Undo semantics.

  • undo() (L131-138) writes the whole snapshot back, discarding any edits the user made in an external editor after the snapshot. Consider comparing the current file to the post-mutation text and refusing (or warning) when it has changed underneath.
  • When the server returns { changed: false } it takes no snapshot, but the client still calls setUndo(true) (index.html L529) — pressing Undo then reverts to an older, unrelated state. Only enable Undo when d.changed is true.

3. Suggestions (non-blocking)

  • exampleQueries: [] in plugin.json — the zh-CN README tells users to say "打开模型管理器", but the Agent can only route that if the manifest lists example queries. Consider adding "Open Model Manager" / "打开模型管理器".
  • .ghead is a div[role="button"] that contains real <button>s (index.html L401-460). Nested interactive controls are not reachable for assistive tech; make the header itself a <button> and place the group actions next to it.
  • Emoji icons (🔄, L162/L181) and the infinite banner-pulse animation: the built-in miniapp-creator Skill's visual baseline asks for inline SVG icons and restrained motion. Also --mcode-warning-bg is not part of the baseline token set.
  • The right-click menu (L319-355) has role="menu" but no keyboard entry, Escape handling, or focus return.
  • /api/providers returns configPath (an absolute path the client never uses) and 500 responses include the raw error message; both leak internal paths to the page.
  • backups/ grows without bound — consider keeping the last N.
  • Plugin ID openrouter-model-manager vs. "works with any provider": keeping the ID is fine (IDs should be stable), but a one-line note in the README would avoid confusion.

Happy to re-review once §1 and §2 are addressed. Thanks again!

…1.2.3)

server.mjs:
- resolve config.yaml from the runtime dataDir first, ~/.minimax fallback
- preserve each line's own terminator (CRLF/LF/CR mixed files stay intact)
- atomic write keeps the original file mode; temp file removed on failure
- serialize mutations behind a promise queue so overlapping requests cannot clobber each other or the undo snapshot
- one-level undo now refuses (stale) if the file changed outside the app since the snapshot
- backups moved next to config.yaml (backups/), pruned to newest 20
- 500 responses no longer leak raw error text; configPath removed from /api/providers

client (index.html):
- Undo enabled only when something actually changed; stale-undo shows a reload hint
- group headers are real buttons with aria-controls (no nested interactive elements)
- context menu closes on Escape and returns focus to the link
- inline SVG icons (no emoji), no infinite pulse animation, warning colors from the visual-baseline tokens

repo:
- add tests/parser.test.mjs (node --test) covering EOL round-trip, provider discovery, colon ids, enabled flags
- README/README.zh-CN: tested environment (Windows 11, MiniMax Code 3.0.73), macOS/Linux untested, process-spawning + temp-file + backups disclosures, corrected secrets wording, plugin-ID note
- plugin.json: v1.2.3 + exampleQueries
@ocoomber

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, @MyPrototypeWhat — everything is addressed in db10720 (v1.2.3). Point by point:

Required fixes

  • Hardcoded config path (server.mjs ~L15): resolveConfigPath(dataDir) now tries the runtime-provided data directory first and falls back to ~/.minimax/config.yaml.
  • Line endings (~L22–26): splitConfigText captures each line's own terminator (CRLF/LF/CR, including mixed files) and joinConfigText restores the text byte-for-byte outside the edited enabled: line. Covered by tests.
  • Atomic write (~L29–33): writes go to .config.yaml.mm-tmp next to the config and are renamed; the write now preserves the original file mode and the temp file is removed if rename fails.
  • Real tests (was overstated in the PR body): tests/parser.test.mjs ships in the repo (outside the miniapp/ install payload) — 6 tests run with node --test, covering EOL round-trip, provider discovery, colon-in-id keys, commented lines, and enabled-flag handling. The old "24/24 tests" claim was wrong and has been removed from the PR body; the honest split is now: 6 automated parser tests + manual end-to-end verification against a real ~5,900-line config.
  • Secrets wording: corrected in both the READMEs and the PR body — the server returns only id / name / enabled / contextLimit; key material is never read into or returned by the UI. The incorrect [SECRET:...] placeholder claim is gone.

Data-safety fixes

  • Undo race (~L131–138): /api/set, /api/bulk, and /api/undo now run through a mutation queue (withMutationLock), so overlapping requests can't interleave read→modify→write cycles. The snapshot stores {prev, after} and undo() returns {stale: true} — the client shows a "config changed elsewhere, reload" message — if the file changed underneath since the mutation.
  • Bulk race (~L164–181): same queue; bulk requests are serialized instead of racing.
  • Path leak (~L274): /api/providers no longer returns configPath.
  • 500 leak (~L366): responses now return {error: 'internal_error'}; details go only to the runtime logger.
  • open-external (~L215–250): URLs must match ^https://openrouter\.ai/<vendor>/<model>$ (no extra path segments); the OS-spawning behavior (rundll32/cmd/explorer on Windows, open on macOS, xdg-open on Linux) is now disclosed in the READMEs.

Suggestions taken

  • Backups moved to a backups/ folder next to config.yaml (follows the config location), pruned to the newest 20.
  • READMEs (EN + zh) gained a "Tested environment" section — Windows 11 (build 26200), MiniMax Code 3.0.73; macOS/Linux explicitly marked untested — plus temp-file, backups, and process-spawning disclosures, and a note that the legacy plugin ID still works with any provider.
  • plugin.json: exampleQueries added ("Open Model Manager" / "打开模型管理器"), version bumped to 1.2.3.
  • Client a11y/visual polish: group headers are now real <button>s with aria-controls (no nested interactive elements), the context menu closes on Escape and returns focus to the link, Undo is enabled only when something actually changed, and the restart pill/banner use inline SVG icons with the visual baseline's warning tokens (no pulsing animation).

Happy to adjust anything further — thanks again.

@MyPrototypeWhat

MyPrototypeWhat commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for the fast and thorough turnaround — I went through db10720 item by item against the code rather than the summary, and also ran node --test tests/parser.test.mjs here (macOS, Node v24.14.0): 6/6 pass, including the mixed CRLF/LF/CR round-trip.

Confirmed resolved: tested-environment section in both READMEs; process-spawning and temp-file disclosure; PR body no longer claims [SECRET:…] or "24/24"; file mode preserved + temp file removed on failure (server.mjs L69-88); withMutationLock serializing /api/set, /api/bulk, /api/undo; per-line EOL preservation; {prev, after} snapshot with stale refusal; Undo enabled only on d.changed; configPath and raw error text no longer returned; exampleQueries; real <button> group headers with aria-controls; Escape/focus handling on the context menu; inline SVG + baseline warning tokens, no pulse. Nice work.

Two things remain — one required, one your call.

A. Required (pick either option): README vs. actual config resolution

The README now says: "the runtime resolves config.yaml from its data directory first and falls back to the default ~/.minimax/config.yaml." The first branch never fires, so the sentence describes behavior that doesn't exist.

Why: context.dataDir is not the MiniMax Code data root. The Host hands each Mini App an isolated, plugin-owned subdirectory several levels below the data root. The Host's own config.yaml lives at <dataRoot>/config.yaml. So join(context.dataDir, 'config.yaml') (server.mjs L26) points at a file nothing ever writes, existsSync is always false, and the code always takes the ~/.minimax fallback — exactly the v1.2.2 behavior. It looks fine on a default layout because the fallback hits every time.

Reproduction (real server.mjs, Host-shaped context, HOME pointed at an empty directory, config.yaml placed only at <dataRoot>/config.yaml):

GET /api/providers -> 500 {"error":"internal_error"}
logger.error miniapp.request.failed:
  ENOENT: open '<context.dataDir>/config.yaml'

Note the path: neither candidate existed, so L31 return candidates[0] resolved CONFIG_PATH inside the plugin data dir rather than to the real config one level up.

Either of these is fine with us:

  1. Reword the README (both languages) to say only the default ~/.minimax layout is supported, and drop the dataDir candidate.
  2. Walk up from context.dataDir to the first ancestor that contains config.yaml (the same pattern plugins/amszuidas/mcode-token-usage-board/miniapp/node/server.mjs L22-36 uses for v2/sessions), then fall back to ~/.minimax/config.yaml. To be transparent: the Mini App contract doesn't promise that the data root is an ancestor of context.dataDir — that's the current Host layout, not an API — so if you go this route, keep the fallback and say in the README that non-default directories rely on that layout. Also change the "neither found" case to fall back to ~/.minimax/config.yaml (or fail with a clear message) instead of candidates[0].

B. Suggestion (your call): backups location

v1.2.3 moved backups from context.dataDir/backups to join(dirname(CONFIG_PATH), 'backups') (L181), i.e. ~/.minimax/backups/ — a new top-level directory inside the Host's data root. Verified: after one /api/bulk, the backup lands in $HOME/.minimax/backups/ and nothing is created under context.dataDir.

The README discloses this accurately, so it's not blocking. But from the Host side we'd prefer the v1.2.2 location (context.dataDir/backups): the Mini App runtime guidance is "store durable state under the injected dataDir"; the Host owns the data-root namespace and may use backups/ itself later; the directory outlives an uninstall; and the prune (L185-189) deletes any config-*.yaml in that shared directory, not just files this plugin wrote. Your decision — if you keep it, the current disclosure is sufficient.

Minor (non-blocking)

  • With serialization in place, a cross-provider bulk still sends one request per provider, and lastSnapshot only keeps the last one — Undo reverts the last provider's batch, not the whole action. Either merge the groups into a single request or note the limit in the README.
  • "not shipped in the install payload" for tests/ is slightly off: it is copied when users copy the directory; it just isn't part of the Host's runtime payload roots (miniapp/client, miniapp/node). Wording only.

Once A is settled, this looks good to merge from our side. Thanks again!

…e whole action (v1.2.4)

server.mjs:
- resolveConfigPath now walks ancestors of context.dataDir looking for config.yaml (matches the current Host layout where context.dataDir is several levels below the data root) and falls back to ~/.minimax/config.yaml; if neither is found it returns the default rather than a missing path
- DATA_DIR captured at startup so backups land under the plugin-owned namespace (context.dataDir/backups), pruned to the newest 20 — matches the 'store durable state under dataDir' runtime guidance and avoids touching anything in the data-root namespace
- /api/bulk now accepts {enabled, providers: [{provider, models}]} so a single bulk request takes one snapshot and one write; Undo reverts the whole action regardless of how many providers it touched

client (index.html):
- bulk() sends one multi-provider request to /api/bulk (instead of one Promise.all per provider); Undo enabled iff any model changed

tests/:
- parser.test.mjs: +1 test covering the multi-provider toggle invariant (ids remain valid across providers in a single write)
- resolveConfigPath.test.mjs (new): 3 tests covering the four-ancestor walk, the default fallback, and a sibling-ancestor config

READMEs (EN + zh):
- config-location: rewrote to describe the parent walk accurately, called out that the walk matches an implementation detail of the current Host layout, and noted the default fallback is what protects non-default installs
- backups: relocated disclosure to dataDir/backups (plugin-owned)
- Undo: noted that multi-provider bulk is reverted as one operation
- Tests/-folder wording: clarified it lives outside the Host's runtime payload roots (miniapp/client, miniapp/node) rather than being excluded from a directory copy
- Tested env: bumped to v1.2.4

plugin.json: 1.2.4
@ocoomber

Copy link
Copy Markdown
Contributor Author

Thanks again for the close read — both items addressed in ff05789 (v1.2.4), plus the two minor nits.

A. Config resolution (required) — went with option 2 (walk up) so non-default data directories still work.

resolveConfigPath(dataDir) now walks parents from context.dataDir looking for an ancestor containing config.yaml (the same pattern mcode-token-usage-board uses for its v2/sessions lookup), and falls back to ~/.minimax/config.yaml. Verified locally with a synthetic layout:

<root>/config.yaml
<root>/v2/plugin-data/liveboards/openrouter-model-manager/

The walk finds <root>/config.yaml four ancestors above the plugin data dir. The README disclosure is honest about the boundary: "the parent walk matches an implementation detail of the current Host layout, not a guaranteed API, so the default fallback is what protects non-default installs." The "neither found" branch now returns the default path instead of candidates[0] — same ENOENT-on-first-read as before, just no surprise path inside dataDir.

Added tests/resolveConfigPath.test.mjs (3 tests) covering the four-ancestor walk, the default fallback when nothing is found, and a sibling-ancestor config being preferred over the default. Total tests now: 10/10 pass.

B. Backups location (your call) — moved back to context.dataDir/backups per the runtime's "store durable state under the injected dataDir" guidance. Captured DATA_DIR once at startup so takeSnapshot writes to join(DATA_DIR, 'backups'). The prune logic now only ever touches files this plugin wrote, in a directory only this plugin uses. Disclosure in both READMEs updated.

Minor items

  • Cross-provider bulk Undo: /api/bulk now accepts {enabled, providers: [{provider, models}]} and does one read → one snapshot → one write, so Undo reverts the whole batch regardless of how many providers it touched. Client bulk() sends one request instead of Promise.all per provider. README notes the guarantee.
  • tests/ wording: reworded — "lives outside the Host's runtime payload roots (miniapp/client, miniapp/node), so the app never loads it — it just rides along if you copy the directory" — which is the actual invariant.

Ready for re-review whenever you are. Thanks!

@MyPrototypeWhat

Copy link
Copy Markdown
Collaborator

Thanks — re-reviewed ff05789 (v1.2.4) with the same harness as before.

Confirmed:

  • Config resolution: with config.yaml only at the data root and an empty $HOME/.minimax, /api/providers now finds the provider. The "neither found" branch returning the default path is the right call.
  • Backups: after a bulk change they land under <context.dataDir>/backups/; nothing is written to ~/.minimax/backups/.
  • Cross-provider bulk + Undo: one request, one snapshot, one write; undo restored both models in one step.
  • node --test tests/parser.test.mjs tests/resolveConfigPath.test.mjs → 10/10.

Two places where the README doesn't match the build — please fix before merge:

  1. Single toggles fail on this version. server.mjs renames the function to setModelsEnabled (L242) but /api/set still calls setModelEnabled (L428):

    POST /api/set {"provider":0,"model":"openai/gpt-4o","enabled":false}
    → 500 {"error":"internal_error"}
    logger: setModelEnabled is not defined
    

    Every per-row switch will bounce back. The README's "Tested environment" line now says plugin 1.2.4 — tested by the author end to end (toggles, …), which can't be true for this build. Please fix the call, re-run a toggle by hand, and keep the version line matching what was actually tested. (setModelsEnabled at L242 then has no callers — fold it back or rename the call.)

  2. The documented test command doesn't run. node --test tests/ treats tests/ as a single module and fails with MODULE_NOT_FOUND — reproduced on Node 22.23.2 and 24.14.0. Use node --test (no args; it globs **/*.test.mjs) or list the files explicitly.

Non-blocking suggestions:

  • tests/resolveConfigPath.test.mjs re-implements the algorithm instead of importing it, so it can't catch production drift (and the two copies already differ: dirname() vs. split(sep)). Exporting resolveConfigPath from server.mjs and testing the real function would make it meaningful. While there, a generic synthetic layout such as <root>/a/b/c/<pluginId> is enough for the fixtures — the specific directory names in the current fixtures and in the L26 comment are Host implementation detail, not something the plugin should encode; the README's "several levels below the data root" is the accurate description.
  • /api/bulk now silently skips an unknown provider index (continue at L273; previously a 500). Fine either way, but the client then just reports "Updated 0 models" with no hint why.

Once (1) and (2) are in, this is good to merge from our side. Thanks for the quick turnarounds.

MyPrototypeWhat added a commit that referenced this pull request Sep 23, 2026
…ecks

## What changed

Contributors — most of them working with AI coding agents — had no way to learn the Mini App package contract without access to the desktop app. This branch puts the contract in the repository and gates pull requests on it.

- `AGENTS.md`: the entry point coding agents load automatically — steps, hard rules, review policy, navigation.
- `docs/package-contract.md`, `docs/runtime.md`, `docs/security.md`: layout, the three manifests, portable paths and limits, the Node runtime contract, and the security rules. Each states the MiniMax Code version it was verified against.
- `examples/hello-miniapp/`: the smallest valid package, copyable as a starting point. Its README doubles as the template for the two headings the check looks for.
- `npm run check`: a zero-dependency validator (`scripts/validate.mjs`) plus its tests (`node --test`, 55 cases). It checks manifest field sets, patterns, enums and limits; portable paths and package size; `node_modules`; the Node entry's `start` export and stdout use; README headings (warning only); and, repository-wide, plugin-ID uniqueness and root README links. Plugin capabilities (Skills, MCP servers, hooks, host bindings, MCP endpoints, Connector access) are validated for shape only; MiniMax Code validates their contents at install time.
- `.github/workflows/ci.yml`: runs `npm run check` on every pull request and push to `main`.
- `CONTRIBUTING*.md` / `README*.md`: point at the example, the docs, and the check. The MiniApps tables and the two existing community packages are untouched.


## Validation

- `npm ci && npm run check` on the branch head: `examples/hello-miniapp` OK; `plugins/amszuidas/mcode-token-usage-board` and `plugins/yanhy2000/mcode-usage-monitor` 0 errors (two README-heading warnings each, by design); `repository` OK; 55/55 tests; CI green.
- The package from PR #4 (`openrouter-model-manager`, a package that runs on a real host) validated separately: 0 errors, 1 warning.
- Example runtime: `start(context)` → `GET /dashboard` 200 → `dispose()` frees the port; nothing written to stdout.
- Example installed into a real MiniMax Code (3.0.73, macOS) and opened through the Agent: page rendered.

## Not covered

- No runtime smoke test in CI (a follow-up).
- README headings are a warning, not an error, until the existing packages adopt them.
- The validator's constants mirror the host's reader by hand; the `Verified against` markers and header comments in the host repository are the sync mechanism.
@ocoomber

Copy link
Copy Markdown
Contributor Author

Thanks — both blockers are fixed in 98279e4. The single-model helper is named setModelEnabled again, so POST /api/set now reaches the real implementation. I verified the installed v1.2.4 plugin in MiniMax Code with a per-row toggle, and added tests/api-set.test.mjs to exercise the real HTTP endpoint and confirm config.yaml changes. Both READMEs now document node --test. Result: 11/11 plugin tests pass. I also merged the latest upstream main, kept both new app-table entries while resolving the README overlap, and updated the PR description to match the current test suite.

@MyPrototypeWhat
MyPrototypeWhat merged commit 6c3a849 into MiniMax-AI:main Sep 23, 2026
2 checks passed
avatasia added a commit to avatasia/MiniMax-Code-MiniApps that referenced this pull request Oct 12, 2026
R6 允许控件所在层与它写入的层不同,条件是这对关系被写下来。此前它只存在于
docs/LAYERS.md 的一句话里,没有任何断言,于是改位置或改归属时没有一处会先红。

verify-cross-layer.mjs(12 条)+ mutate-cross-layer.py(6 条变异全捕)守的是配对,
不是整洁:DOM 父节点说这个控件被呈现成哪一层,赋值说它写哪一层,改任何一半而不
声明,配对就不成立。

  声明 1  #window-bar 在 L5 面板内、写 L3(只写模块级 ledgerWindow / ledgerProgress)
  声明 2  #overview 持有写 L2 字段 state.overviewSelection 的控件

六条变异分别对应六条具名断言,守卫必须按名字报出:把 window-bar 挪出面板、给它加
一个 state. 赋值、给进度写者加一个、把 state.overviewSelection 塞进窗口条、让刷选
落点不再写该字段、让 L2 入口不再读它。

第 6 条变异重锚过一次:var selection = state.overviewSelection; 在
renderOverviewSelection() 里也有一处,锚在 visibleEntries() 的函数头上,否则命中
的是 L3 的那个读者,而 L2 入口会保持完好、变异存活。变异不删,重锚。

本条刻意不覆盖 R5,而 R5 没有闭合:state.overviewSelection 今天有 5 个写点
(clearOverviewSelection / setActualDuration / onOverviewPointerMove /
onOverviewPointerUp / resetBaseFilters),没有一个是 R5 要求的那个单一 setter。
断言「已经收敛」是一条过不了的守卫,断言「当前是 5 个」是一条恒真的守卫,两条都
是错的写法,所以这个缺口留在文档里而不是被断言盖住。收它属于 D4。

同时把两项设计写进 docs/LAYERS.md,都标为「设计已完成,等指示」,未写实现代码:
按 AGENTS.md 的硬约束,四个问题的答案落文档之前不写实现。

  Stage 2(异常轴建模,MiniMax-AI#4)读点已数清:anomalyRules 4 写 13 读、multiAnomaly
  3 写 2 读、anomalyLast 7 写 1 读。R2 单一入口成立——flattenTurns() 产出
  {turn, record},所以两个入口拿到的本来就是同一个 turn 对象。对外口径不变,
  但 state 形状变,verify-anomaly.mjs(53 KB)与 mutate-anomaly.py(29 KB)里
  守形状的断言要逐条改写、守行为的原样保留,一条都不删。建议分三次独立提交。

  Stage 4(增量协议 ?since=,MiniMax-AI#2)调研结论改变了收益估计:payload 只有 session /
  stats / turns 三个键,41 MB 全在 turns,而 stats 是累加器必须全量重算——所以
  ?since= 省的是传输与客户端 JSON.parse,不是服务端 CPU。游标选 record.index。
  这是本轮唯一一处真口径变更(applyPayload 从整体替换变追加合并),读点已列全,
  写侧只有 1 处。

产品代码零改动:index.html 仍为 310,348 bytes,套件 36 → 38 条全绿。
MyPrototypeWhat pushed a commit that referenced this pull request Oct 14, 2026
…e machine (#16)

* test(mmc-trajectory): ship the guards that were only on one machine

The PR #15 review asked whether the rule-name guard could be reproduced, and noted that the
script behind it is not in the repository. It was not: the mutation controls and verifiers lived
in a scratch directory on the machine that wrote the client, with absolute paths in them.

Fifteen of them now live in tests/, and they run on a clean checkout with nothing but Node and
Python:

    node tests/run.mjs

Every path resolves from the directory's own location, and client-check.mjs writes its extracted
copy of the inline script to a temporary directory rather than into the checkout.

Why half of them are mutate-*.py: a guard that cannot fail is not a guard, because it reads as
coverage. Each one breaks exactly one rule in the shipped source, requires the guard that owns
that rule to fail *and* to name the right check, then restores the file and requires green again.
Two of the six report "all N mutations caught"; the rest print each mutation and the check that
caught it.

Four were left out rather than carried:

  mutate-filter-guards.mjs, mutate-qa-lens.py
      Guard the layer buttons and the turn lens. Commit 91464cd removed the layer presets in
      favour of the type chips; turns-btn, setLayer, LAYER_KINDS and applyLayerToKinds now occur
      zero times in index.html. The rule they guard is gone.

  mutate-steer.py, mutate-window-default.py
      The rules are still live -- verify-window-widen.mjs still covers the second one -- but the
      mutation anchors no longer match the source. These need re-anchoring, which is a separate
      change; shipping them red would only teach reviewers to ignore the suite.

tests/README.md records the same reasoning, and states plainly that the one-shot patch scripts
that write the package are not here and must not be run: they have no dry-run and no backup, they
write straight back to index.html / server.mjs / the READMEs, and re-running one edits text that
has since moved on.

15/15 pass. The four payload files are byte-identical to main -- this commit adds only tests/.

* test(mmc-trajectory): make the session-store guards machine-independent

verify-parsers.mjs and verify-prompt-fix.mjs read this machine's ~/.minimax.
On a runner without one the suite drops to 13/15: verify-parsers crashes and
verify-prompt-fix fails two checks, so the guards would have gone red the
moment CI was allowed to run.

They now read a committed synthetic store under tests/fixtures/minimax-home.
The fixture is distilled from real transcripts rather than hand-written:
make-fixtures.mjs keeps the structure the assertions depend on -- roles,
content-part types, stopReason (the reply/narration split turns on it),
toolCallId pairing, usage numbers, and the injection markers
splitUserMessage() branches on -- and replaces every string payload.

Three things keep that honest:

- a leak assertion on the rendered bytes refuses to write if a path, user
  name or store reference survived synthesis (--selftest proves it trips);
- a coverage gate refuses to write unless all eight required shapes are
  present, so a sparse sample cannot pass by accident;
- toolCall id / toolResult toolCallId are memoised per original value, so
  corpus pairing survives -- regenerating each occurrence independently
  silently broke it and the pairing assertion would have failed.

`node tests/run.mjs --real` still runs the two guards against the local
~/.minimax, for checking a change against transcripts nobody curated.

Also corrects verify-prompt-fix.mjs, which passed resolveSessionsRoot().home
to createRedactor -- that property does not exist on the returned object, so
the store root was never one of the redacted secrets.

15/15 on this machine, 15/15 on a runner with no ~/.minimax, 15/15 with
--real. The four payload files are byte-identical to main; this adds only
tests/. npm run validate reports plugins/avatasia/mmc-trajectory: OK.

* test(mmc-trajectory): guard the rest, with no service and no machine of its own

The suite went 15 -> 22. The six guards that were only runnable against a booted service on a
developer machine now read a committed payload instead, and the two mutation controls whose
anchors had stopped matching work again.

Seven guards wanted one /api/trajectory body and got it by fetching it from a port read out of
.probe/port.txt. The route is buildTrajectoryPayload(...) plus a few session.* fields, so the
same body falls out of the fixture store offline -- make-payload.mjs writes it, with --check to
catch drift between the two fixtures.

Distilling that store properly needed the whole lineage. A compacted session keeps its old
messages in snapshots/g<gen>--ctx_*.jsonl, and the shapes the guards need -- an empty reply
above all -- live in those older generations. Reading only the active file found none of them and
verify-anomaly failed for want of something to find. The distiller now walks the lineage and the
coverage gate requires all ten shapes, including one that produced no segment at all: server.mjs
only pushes a text:null reply when segmentOrder is empty, so an assistant message that called a
tool but said nothing is not a silent reply.

Two of these were not what they looked like:

- mutate-steer.py was not stale. server.mjs is CRLF and the anchor joined its lines with \n, so
  a multi-line anchor matched zero times while every single-line anchor in the same file matched
  fine. Anchors are now built in the file's own line ending.
- mutate-window-default.py was stale, but not where it looked: the rule is intact and rePinWindow
  had gained a time-range early-return *between* the two lines the anchor spanned.

verify-block-order.mjs was wrong about the rule it names. It broke a run only on a non-assistant
record, so two assistant messages in one turn were ranked against each other and three
toolCall -> thinking pairs were reported that never occurred inside any message. The rule is
per-message; splitting runs at the message boundary finds more runs, not fewer (16 -> 19, 0
violations).

mutate-anomaly.py does not carry a case that nulls the page's liveTurn, even though that looks
like the obvious thing to break. verify-anomaly.mjs re-derives the live turn itself instead of
calling the page's function, so the mutation is invisible to every assertion in the file -- tried,
and the probe passed. Shipping it would ship a mutation known to be unobservable. The gap is
named in tests/README.md instead.

verify-time-range.mjs and mutate-time-range.py are present but skipped by run.mjs: their
assertions are calibrated against a full multi-generation session (stats.messages > 1000, a
checkpoint nested inside a turn) and reproducing that means committing a multi-megabyte payload.
Relaxing the numbers would stop exercising the timezone arithmetic the file exists for.

22/22 here, 22/22 on a runner with no ~/.minimax, 22/22 with --real. The four payload files are
byte-identical to main; this adds and changes only tests/. npm run validate reports
plugins/avatasia/mmc-trajectory: OK.

* Remove the commit-message scratch file that git add swept in

* Give 异常 its own axis, and make its counts mean what they say

The 异常 row was the last field of the search form, which put it above the KPI
cards and the type axis — four cards away from the control it reads as part of.
It now sits directly under 全部/用户/追加, because both axes answer the same
question: which records the ledger shows.

It is a second filter system rather than another field of the first. Ticking a
rule switches the type axis, the search box, the time range and the overview
brush off; a new 全部数据 button — the state the page opens in — switches them
back on with their values intact. The inactive controls are `inert`, not merely
dimmed: a control that takes a click and then changes nothing on screen is the
defect this change is about, and reintroducing it one line away would be worse
than the original. The session and generation pickers are deliberately left
working — they choose which session is on screen, not which records of it.

The counts followed. anomalyCounts() walked passesBaseFilter while the ledger
filtered the same way, so a button promising 283 rows sat above a list of
1,494 — reproducible on a 15k-record session, and the arithmetic was right in
both places, which is why nothing caught it. It now walks the records anomaly
mode actually shows, so the number on a button is what ticking that button
puts on screen. A rule scoped to a turn is counted once per turn and published
in 轮: 无答复 was advertising 1,244 "unanswered" rows in a session that was
broken 13 times, none of which carried a sign of being unanswered. The unit
comes from the rule table, so a rename or a rescope cannot leave it lying.

verify-anomaly.mjs 116 → 158 assertions. The two that required the count to
follow the other axis are inverted rather than deleted — they were correct
while the ledger filtered the same way, and they are the reason the mismatch
had a written spec. Added: the unit follows the rule table, a turn rule is
counted per turn, the axes are alternatives rather than a stack in both the
ledger and the overview strip, the way back exists and is not a toggle, the
inactive axis is inert, and the position of the axis on the page.

mutate-anomaly.py 36 → 45 mutations, one per new invariant. Two cases were
repointed and one deleted: they encoded the stacked-axis behaviour that has
just been removed, and the anchor of a third had moved with the markup.

Also fixes a dead class — `.anomaly-btn.is-empty` was toggled on every render
and had no rule anywhere in the stylesheet.

22/22 locally, 22/22 on a clean runner, 22/22 with --real.

* Make 重置 a reset, and put the row above back when a rule is ticked

The button added in the previous commit as 全部数据 was neither of those things.
It is 重置: not a reverse selection — nothing is being inverted — but the row
returning to the state it was in on arrival, where the table is filtered by the
row above.

That distinction has a consequence worth spelling out. In 异常 mode the row above
filters nothing, so a 重置 that restored what that row used to hold would be
restoring a condition the page is not applying. It resets it instead — and so
does ticking a rule, on the way in. Otherwise the axis keeps drawing 「用户」
while the table is showing tool failures: the same defect as a button whose
number does not match its list, one control over.

Ticking a rule therefore puts the row above back to its default — every type, no
search, no range, no brush. `resetBaseFilters()` sets the state *and* calls
`syncKindButtons()`, because a write without the sync is what leaves the axis
drawing what was there before. That combination has its own mutation control.

重置 also drops its count. 「怎么多会留下多少」 is a question about a rule, and a
control that keeps whatever the row above says has no answer to it; printing a
number there only invites adding it up against the numbers beside it.

verify-anomaly.mjs 158 → 172 assertions. mutate-anomaly.py 45 → 50 mutations,
five new controls for the reset. One of them survived the first run, because the
assertion that catches it was in client-check.mjs and this runner does not
execute that file; the assertion moved to the file whose runner can, rather than
being dropped. client-check's "state.kinds is written from exactly three
controls" becomes four, for the reason its own comment demands — change the check
in the same commit that adds the control.

22/22 locally, 22/22 on a runner with no ~/.minimax, 22/22 with --real.

* 异常轴加「多选」:默认单选,勾选后可叠加

多选是一个模式开关,不是第五条规则:它在规则按钮组之外,所以不会被
syncAnomalyMode 的 data-filter-scope 打成 inert——那会把它唯一一条退路掐掉。

单选语义下点第二条是替换而非叠加,重复点同一条才关掉;多选下点第二条才叠加,
点已选中的那条只摘掉它。关掉多选时保留最后勾选的那条(读者刚才在看的那条),
而不是表格第一条。重置把规则和多选一起清回默认,并复位 checkbox。

守卫:verify-anomaly 172 -> 196,mutate-anomaly 50 -> 62。
- 两条旧断言(ticking a second keeps table order / unticking removes only that
  one)编码的是 toggle 无条件叠加的旧语义,本轮按需求推翻重写:单选写到单选
  块,原来的并集语义挪到多选块重断言,不是删掉。
- 新增覆盖:关多选保留最后勾选那条、多选开关不改变已选集合、重置同时关掉多选
  并复位 checkbox、每次 render 从 state 重绘 checkbox、checkbox 不得带
  data-filter-scope、change 而非 click 绑定。
- 修了 FAILED_NAMES 用 split(':')[0] 取名会把带冒号的断言名截断的 bug——它
  让变异跑器把「已抓住」读成「没抓住」。名字改为单独收集,可读输出不变。

浏览器实测(大会话 126 轮):单选替换、并集叠加(343 = 200 已显示 + 143 未
加载,与 324+19 有 1 条重叠吻合)、关多选保留最后勾选那条、重置回到默认且
checkbox 视觉复位。

* 异常轴复审:修正三处控件状态与合计口径,并沉淀验证方法论

黑盒走了一遍按钮矩阵,发现三处缺陷——都是「控件说了什么」而不是「控件算了什么」,
读代码读不出来,因为算的那部分是对的。

1. 部分并集时,提示把「合计」指向「全部异常」。实测:多选下勾 工具失败+上下文压缩,
   提示写「合计见「全部异常」」,而「全部异常」是四条规则的全集 1,707 条,当前所选的合计
   约 354 行。按提示去找合计,拿到的是另一个选择的数。
   改为在 anomalyCounts() 同一次遍历里数出所选规则的并集,提示直接给这个数。

2. 多选已勾但未选规则时,「重置」自称「已是默认状态」且 aria-pressed=true。实测证据:
   多选可见、规则全未选,重置仍读「已是默认状态」。但默认是单选,而且多选会改变之后每次
   点击的语义(替换 vs 叠加),重置是唯一能把模式拨回去的控件——告诉读者已经在默认态,
   他就没有理由去按它。

3. 单选下按「全部异常」会用多选词汇描述状态:提示写「已叠加 4 条规则」,而多选是关的,
   读者并没有叠加。改为「全部 4 条规则同时生效」。

顺带把 counts 改成单次快照:render() 原来为提示和按钮各走一遍,而会话在轮询间隙还在长,
两遍可能给出不同数字。现在一次 anomalyCounts() 同时喂两者——提示和按钮不可能各答各的。

守卫:verify-anomaly 214 -> 234,mutate-anomaly 62 -> 70。
- 补上了此前根本没测的部分并集:�ll.any 是四条规则的全集,逐条规则的检查又只选一条,
  所以多选唯一能产出的那种选择此前一条断言都没有。现在对 6 个规则对逐一断言
  counts.selected == visibleEntries().length,并用与页面无关的代码独立算出期望值。
- 两条新变异第一次跑就存活:一条是我把变异写成了空操作(sel.length === 0 || 在有选中时
  等价于原式),换成了「去掉 break」——只在两条规则重叠于一条记录时才出错的那种;另一条
  指向了不存在的断言。两条都是补守卫,不是删变异。
- 每条规则对的断言名带规则对,人读着好用,但变异跑器按名字精确匹配,哪一对命中取决于夹具。
  因此另加族级断言(名字稳定)作为变异的归属,逐对的保留作诊断。

新增 tests/METHODOLOGY.md:验证方法论。每条规则都对应一次真实代价,包括本轮踩的——
读父容器而不是过滤子元素、期望值只能来自冻结夹具、空操作变异、断言名不能带冒号、
散文要跑出来断言而不是看源码形状、以及工具驱不动的事要写明驱不动。

浏览器实测:多选两条 → 提示「合计 354 条」,账本 200 已显示 + 154 未加载 = 354;
单选按全部异常 → 「全部 4 条规则同时生效 · 合计 1,716 条」,账本 200 + 1,516 = 1,716;
多选未选规则 → 重置改为「点击回到默认状态」。

未验证:多选 checkbox 的键盘操作。Shift+Tab 在该 harness 里移不动焦点(焦点环仍在重置上,
Space 无效),所以该结论只由静态守卫(绑定 change 而非 click,配变异对照)加原生行为支撑,
不做端到端声称。

* 把两条筛选轴钉成一块,并把下方每层的偏移改成实测

滚动时看不到异常筛选:类型轴自己 claim 过 position: sticky,异常轴没有,于是
筛选过的会话滚下去之后,页面上没有任何一行还在说明「现在被筛过什么」。

- `.filter-sticky` 把类型轴和异常轴包成一个钉在 top: 0 的不透明块。它们回答
  同一个问题(账本显示哪些记录),分开钉就会出现「类型轴还在、异常轴滚走了」
  的半截状态,比完全不钉更糟。
- 下方三层的偏移不再写死。原来的 0–32 / 32–83 只在「总览条上方只有一行筛选轴」
  的布局里成立,而异常轴会随窄屏换行、提示行的出现和消失改变高度,任何常量都会
  悄悄变成重叠。`syncStickyOffsets()` 在 `render()` 末尾量三个块,写进
  `--sticky-band-h` / `--sticky-overview-h` / `--sticky-window-h`,样式表只读变量。
- 轮头从 `top: 0; z-index: 2` 移到实测偏移下方。它原先压在总览刻度和类型轴按钮
  上,两处都读不出来;z-index 6 的筛选带盖住它只是把重叠藏起来,不是修好。

守卫:`verify-sticky.mjs`(20 条断言)把 syncStickyOffsets() 拿 137 / 65 / 57 的
stub 高度跑一遍——文件里没有任何常量等于这三个数,所以「写数字而不是量」的版本
过不去,把 band 量成 strip 的版本也过不去。`mutate-sticky.py` 18 条变异逐条要求
它以正确的名字失败,其中三条是补了守卫才有的:样式表读到的变量集合必须等于测量
写出的集合(单向都不够)、被测量的块必须真的绑定到 markup 里存在的 id(stub 永远
喂得起函数,抓不到这种静默回退)、以及任何钉在账本之上的层都必须有不透明背景
(账本行是 `background: transparent`)。

测试 22 → 24 个守卫,全绿。

* 补一份分层设计:功能类型 → 层 → 归属

这个页面的筛选逻辑是一次加一个功能长出来的,代价能逐条指到代码:

- 「看哪些记录」这一个职责住在文档位置 3 和 5 两处(工具条 / 筛选带),
  中间隔着整块 KPI 区。上一轮的 sticky 修复是这笔债的利息不是本金。
- state 里两句注释互相矛盾:`kinds` 写「类型选项是唯一筛选」,`rangeFrom`
  写「区间是 KPI 跟随的唯一筛选」。两句各自描述一个更早的页面。
- kpiStats() 只跟区间,不跟类型轴、不跟搜索、不跟异常轴——汇总层和筛选层
  共用了同一套视觉语言。
- 异常轴被实现成「第二套系统」而不是筛选层的一个字段,于是互斥只能散落在
  两个渲染函数里手写。上一轮修的三处缺陷没有一处是算错,全是「两个筛选器
  各自推理自己」的症状。
- window-bar 在列表面板里,写的却是「渲染多少」这一层的事。

docs/LAYERS.md 给出 L0–L6 的归属表、六条不可违反的规则,以及实现之前必须先
答的四个问题:归层、关联、布局、口径。最后一问是「联动异常」的主要来源——
加了一个能力改了一个数字,别的地方还在按旧口径说话。

本 PR 不改页面代码,Stage 1–3 在文档里分阶段列出。

* Stage 1 冻结 spec:先写清四问与不变量,再动结构

按 AGENTS.md 的强约束,Stage 1 在实现之前先把设计冻结成可判定的断言。

docs/STAGE1-SPEC.md:
- 四问在本阶段的答案。L2 容器装四组控件(搜索 / 区间 / 类型轴 / 异常轴),
  L1 的会话与上下文代留在外面,不动 Stage 2 的模型,一个数字都不改。
- 口径读点清单:KPI↔区间这条口径共有 4 个页面读点、4 个守卫读点。其中
  verify-time-range.mjs 在 run.mjs 的 SKIP 集合里默认不跑,所以这条口径在
  默认套件里目前没有保护——而 Stage 1 恰好要动 KPI 的位置,所以必须补一条
  能默认跑的断言,不能依赖那个跳过文件。
- 10 条断言,每条都写了「必须拒绝的错误实现」和配套变异。其中 V7(把区间塞进
  类型轴)是这一阶段最容易犯的错。
- 4.1 节记了搬家会引出、原 scope 没写的后果,逐条对过代码:
  F1 .toolbar-row 承载区间那行的 flex,移出表单后静默失配;
  F2 form#toolbar 的 submit handler 只为拦搜索框的隐式提交,搬走后表单里只剩
     两个 select,role="search" 成了假话;
  F3 异常模式的 inert 靠后代选择器 [data-filter-scope],搬家后仍生效,异常轴
     本身不带该属性(否则会把自己 inert 掉);
  F4 applyFilterScope() 名字说 L2、只写 L4 的口径行——记进 LAYERS.md 的 D7,
     本阶段不改名,留给 Stage 2。

跨家族只读查漏试了 codex / claude / codebuddy,三个都被认证挡住(模型不被
ChatGPT 账号支持 / OAuth 过期 / 需要 /login),三次都设了只读机制且比对过文件
哈希,未被改动。所以这份 spec 是「还没查过漏的」,已在 7.1 节写明解锁方式。

24/24 守卫全绿,validate OK。

* Stage 1 spec 修订:跨家族查漏的 15 条结论,改了 6 条断言、加了 3 条后果

用 codex / gpt-5.6-sol(OpenAI,与本会话的模型不同家族)做只读查漏。
Windows 沙箱在 exec 下初始化失败,第一次交白卷——它明确拒绝捏造文件:行,
且哈希确认未改动文件。改为把材料内联进 stdin 后跑通。
只有 gpt-6.1-sol 被 ChatGPT 账号拒绝,gpt-5.6-sol 等可用。

它找对的(已接受并改进 spec):
- V1 的变异是空操作:把 KPI 接在四组之后时四组仍连续,V1 照样通过。
  这是我给守卫定的规矩(变异存活就补守卫)在自己身上失效了。改成把 KPI
  插在搜索与区间之间,并让 V2 用「接在四组之后」的互不等价变异。
- V1/V2 共用同一变异,等于其中一条没被测到。已拆开。
- V10 只覆盖一种丢标记方式。拆成 V10a/b/c + V11。
- 第 4 问的口径表漏了一个读点:#kpi-scope 不只是三句措辞,还发布
  stats.messages 与 stats.turnsWithPrompt 两个数字。
- 同一份表里我把写 #kpi-scope 的函数写成了 updateKpiScope()——那个函数
  不存在,真正的写入者是 applyFilterScope(),也就是 D7 说「名字与职责不符」
  的那个。我在自己的文档里把它的名字写错了一次。
- 「保留 form 因为 select 需要 label 关联」是错的:for/id 关联与是否在
  form 内无关。改为直接删掉失去对象的 submit handler。
- verify-sticky.mjs「只改指向」不足:现有子树断言只要求类型轴与异常轴同在
  band 内,扩到四组必须重写成员与排他性,并显式排除 L1 与 L4。
- I1 用下标比大小发现不了中间的插入;改为解析容器子节点的标签序列。
- I4 的不透明判断太粗;限定为已定义的 --mcode-* token。
- 焦点顺序变化(会话→代→搜索→区间→KPI 变成 KPI 插进筛选流程中间)不能列为
  「不验证」——DOM 顺序正是本阶段直接改的东西。新增 F5 并撤销原排除。
- aria-controls 只指向 overview、而同一筛选也控制账本;L2 容器也没有可访问
  名称。新增 F6。
- 窄屏下四组全 sticky 可能占满视口。新增 F7 与 B1–B4 四条浏览器补测。

它指错的(已驳回):
- 「目前没有保护」过度表述。spec 第 94 行原文是「在默认套件里目前没有保护」,
  已经限定范围;它引的第 88 行是表格行,那行里没有这句话。
- verify-sticky.mjs:79 的行号落在 value() 这个辅助函数上,不是它要谈的
  子树成员断言。

断言 10 条 -> 15 条(V10 拆三条 + 新增 V11/V12),后果 4 条 -> 7 条,
浏览器补测 4 条。spec 第 8 节留了完整对照表,含驳回项与理由。

24/24 守卫全绿,validate OK。spec 仍未冻结,需再查漏一轮。

* Make the inspector follow the table, and own the re-measure at one exit

Selecting a record and then changing any filter left the inspector describing a
row that was no longer on screen. Two functions decided what 'selected' meant
and they read different lists: the rows were built from visibleEntries(), while
revealSelection() and selectedRecord() scanned state.flat — the whole session,
unfiltered. Worse, the only caller of revealSelection() was applyPayload(), and
only when the payload signature changed, so no local filter change ever reached
it. The ledger read 已显示 2 条, the inspector still named the old record, and no
row anywhere carried the selection mark; 复制 JSON and 交给 AI 分析这条 read the
same stale record and stayed enabled.

revealSelection() is now handed the very array the rows are built from and is
called from renderLedger(), the one exit every path that changes what the table
shows goes through — nine of them call it directly instead of calling render().
It reports whether it dropped the selection, and the exit redraws the panel when
it did: clearing the id alone is not enough, because those nine callers never
redraw it, so the search box left the panel naming a record that had just been
dropped. selectedRecord() stays a plain lookup and documents why that is safe.

The sticky offsets had the same shape of problem. syncStickyOffsets() had exactly
one call site — the end of render() — while nine writers change what the table
shows without going through render(), so the invariant lived in a convention
rather than in the structure. There was also no resize hook at all, and on a
session whose payload has stopped growing every poll is a 304 and render() never
runs, so a window resize that wrapped the type or 异常 row left the pinned strips
at the old width indefinitely. The measurement now runs at the same renderLedger()
exit, and resize re-measures through a requestAnimationFrame so a window drag
does not measure on every event.

docs/LAYERS.md D1 is withdrawn. Treating the filter controls being split across
two regions as structural debt was wrong: query conditions are an aggregation, and
the frontend UI being separate from the request construction is the design.
docs/STAGE1-SPEC.md is marked not-adopted with the reason. D8 and D9 record what
was actually wrong, and both are closed.

Guards 24 -> 26. verify-selection.mjs runs revealSelection() against a state
carrying both the session and the filtered list, so a version that reads the
unfiltered one again answers wrongly instead of throwing; mutate-selection.py
breaks nine of its rules. verify-sticky.mjs gains five assertions for the new
exit and the resize hook; mutate-sticky.py gains seven mutations. One of the nine
mutations survived the first run — it moved the call to the line above the slice,
which still raised state.limit in time, so it changed the text and not the page;
the mutation was rewritten to actually move it past the slice.

* Stop rebuilding the whole lineage on every poll

A finished session costs nothing: the ETag is derived from stat facts, so an
unchanged session is a 304 and nothing is rebuilt. The cost is the session that
is still growing, which is the one a reader is watching on purpose — and there
every poll rebuilt everything from scratch.

Measured on a 23-generation session (payload.session.sizeBytes = 99,986,501):

  * 22 of the 23 generation files are snapshots, written once when the runtime
    rotates generations. Between compactions they do not move at all. Only
    messages.jsonl is appended to — 97,526 bytes, 0.10% of the lineage.
  * trajectoryCacheKey() carries each generation's byteLength, so a growing
    session misses the whole-session cache every poll and re-reads and
    re-parses all 95 MB, 99.90% of it byte-identical to the previous poll.
  * On the wire: 1.31 GB delivered over five minutes to carry 118,875 bytes of
    real growth. 11,006x amplification, 99.99% re-sent.

Two fixes, both on the read path.

Per-generation parsed rows. loadParsedRows() keys a cache on
path+size+mtime+limit and hands back the rows of any generation file that has
not moved. Cold 306 ms, warm 2 ms over the 22 frozen files, measured directly
against the real files. Bounded by bytes rather than entries, because entries
say nothing about volume — one generation file is 40 KB and the next is 10 MB.

  * The key names the path, not the basename: every session has a
    messages.jsonl, so a basename key lets two of them collide. The fixture
    builds exactly that collision — same size, same mtime, different
    conversations — and asserts neither sees the other's rows.
  * The key carries the size and the mtime rather than the catalog's recorded
    byteLength, which this file already documents as a snapshot that goes
    stale and that the active file grows past between polls.
  * The key carries the limit, because readHead truncates at it and the
    truncation verdict is part of what the entry holds.
  * A file that changes between the stat and the read lands on a key that
    describes a size it does not match; the next poll stats again and misses.
    The race can only cost a redundant read.

The untouched message, on demand. buildRawPayload() ran JSON.stringify on every
record to measure it against a 64 KB cap — about 100 MB per rebuild — to
produce a string that was thrown away, because splitRawRecords() strips the
message off the bulk response before it is ever serialized and /raw serves the
message object rather than the string. A row now carries the position of its
message instead, and /raw builds it for the one record that asked.

That moves 「原文过大,已省略」 out of the bulk payload and into the /raw
response. It is a contract change, so every reader is listed in LAYERS.md D11
and asserted by name: none of the five is in the ledger — all of them are on
the path a reader takes after opening a record. The only visible difference is
that the raw tab now spends one request to learn a message was too large
instead of knowing it in advance.

Guards 26 -> 30, mutations 34 -> 58, all caught.

  verify-parsed-rows.mjs   53 assertions. A hit is compared against a cold read
                           by serializing both bodies and requiring them equal,
                           not merely similar. A version that read the wrong
                           source, or keyed on the wrong fields, produces the
                           wrong answer here rather than looking plausible.
  verify-raw-lazy.mjs      39 assertions. The 64 KB cap is asserted through the
                           path that now owns it, rather than dropped: the
                           locator is followed to its row and buildRawPayload()
                           is run there.

Three things the mutations caught in this change and the guards did not have:

  * A locator written as Number(record.rawRow) accepted null as row zero —
    Number(null) is 0 — so /raw would have answered with a different record's
    message. Now rejected rather than coerced.
  * Removing the delete that takes the locator off the row let rawRow ship on
    every record. Nothing asserted its absence.
  * mutate-pr15-fixes.py had a mutation whose anchor this change erased. It was
    reporting SKIP, not a pass, but a dead mutation reads like a green one; it is
    re-anchored to the line that now carries the same decision.

Correction to LAYERS.md D10, which this change's measurement disproved: the
read-and-parse was ~306 ms, not the ~1,000 ms written there. That number was
obtained by subtracting the serializer from the rebuild total, which folded
buildTrajectoryPayload and buildRawPayload into "read and parse". D10's
payoff is therefore ~300 ms of a ~1,400 ms rebuild, and the resident heap is a
measured 116 MB rather than the 130-290 MB estimated there.

* Build the ledger in batches, and stop the inspector widening the window

Two defects, both reported from the same session, both with a cause that was
structural rather than a missing line.

**A — 「显示全部」 froze the page.** Building 18,924 rows inside one
synchronous loop took 3,956 ms of main thread with nothing on screen saying
so. Measured on this machine (150 turns, 18,924 records, 735x900):

    batched build (25 turns/frame)   3,956 ms total
      row construction    median 141 ms / max 151 ms per batch
      between frames      ~617 ms  <- layout + paint of the whole document

The blocking work was not the row construction. It was each frame's layout
pass over a document that was already two million pixels tall, so making the
batches smaller does not help — it multiplies the passes.

The fix keeps the promise and changes the pacing: one rAF builds at most 25
turn groups, then hands the thread back, and the window bar reports
「正在显示 N / M 条」 while it goes. Every pass through renderLedger() bumps
ledgerBuildToken first, so a build still in flight retires itself at its next
step instead of appending into a list that has been cleared underneath it.
The poll tick checks ledgerBuilding() first and remembers the tick — at 3,956
ms against a 2,500 ms interval, letting every tick through would restart the
build before it ever finished and the reader would watch the count reset for
ever. flushDeferredPoll() runs the held tick once the build ends.

Nothing about what the window shows has changed. 「显示全部」 still means every
record.

**Rejected, and recorded in LAYERS.md D12 because it is the obvious next
question:** `content-visibility: auto` on `.turn-group` is a real 4.0x —
3,956 ms to 979 ms, same row count, and the per-frame layout cost disappears
with it. It needs one `contain-intrinsic-size` for elements it skips, and the
150 turn groups span 1,011 px (p25) to 29,754 px (max), median 2,667 px. Any
single value makes the scroll height wrong by a large factor, and `auto` only
remembers sizes of groups that have already rendered. That is trading a
control that describes the page accurately for speed, so it is not shipped.

**B — the inspector survived the record it was describing.** Select an early
record in 「显示全部」, press 「显示最新 200 条」, and the row leaves the table —
but the panel went on naming it. revealSelection() was judged before the
window was sliced and raised state.limit to reach the selection, so the reader's
own click changed the number on the bar a second later:

    press 「显示最新 200 条」   已显示 200 条
    one second later           已显示 15,224 条

Two things wrong with that: the window became something the inspector could
write, which gave state.limit a second writer, and the test was the wrong one —
「what the reader can see」 is the window, not the filter result.

revealSelection() is now a predicate over the rows that will actually be drawn.
It writes state.selectedId and nothing else, and it runs after the slice. A
selection below the window is dropped rather than revealed: the reader's
explicit window choice outranks an earlier click, and it matches what the
filter axes already do.

**Guards**

- tests/verify-selection.mjs — rewritten, 14 assertions. This is a 口径 change,
  not a refactor: the file previously pinned 「the window is pulled down to a
  selection below it」 (asserting limit === 8) and that the judgement ran
  *before* the slice. Both are now the opposite, and the new promise is asserted
  instead, including that the function's source may not mention `limit` at all.
- tests/verify-ledger-batch.mjs — new, 20 assertions over the four things that
  have to hold together: the frame is really handed back; a new exit retires the
  build in flight; progress goes through updateWindowBar, the one writer of that
  count; a poll defers without swallowing the tick.
- tests/mutate-ledger-batch.py — new, 19 mutations, all caught by name.
- tests/mutate-selection.py — two mutations rewritten rather than deleted. The
  old 「the window is never pulled down to the selection」 was guarding the very
  state.limit write this change removes; it now breaks the rule that replaced
  it. The old 「the selection is judged after the window is sliced」 guarded the
  opposite ordering, so it now breaks the new one.
- mutate-sticky.py, mutate-turn-breakdown.py, client-check.mjs — re-anchored
  and tightened where the refactor moved text. The window-bar total is now
  pinned on both of its branches, since it is written twice.

**Verified**

- `node tests/run.mjs` — 32/32 locally and in the fork; `--real` also 32/32
- mutate-selection 9/9, mutate-ledger-batch 19/19, mutate-sticky 14/14,
  mutate-turn-breakdown 12/12
- `npm run validate` — mmc-trajectory OK, repository OK
- Browser, served bytes == disk bytes (302,384): the progress readout observed
  live mid-build at 「正在显示 15,813 / 19,150 条」, then settling to
  「已显示 19,157 条」; with row #1 selected and the window narrowed, the
  inspector returned to 「尚未选择记录」, no row carried aria-current, and the
  count stayed at exactly 「已显示 200 条 · 更早的 18,964 条未加载」 instead of
  silently widening. Console clean.

The remaining lever on the A side is making the two-million-pixel number
smaller — capping 「显示全部」, or the incremental protocol. Both change what the
page promises, so neither was done inside a performance fix.

* Record the eight undone items, and one measured option that was rolled back

Documentation only. No code in this commit: index.html and the tests are
byte-identical to 5b3f29b.

**Added section 7 — the eight undone items**, each with what it is, whether it is
code / verification / process, what blocks it, and the next step. Item 1 is the
one I left myself: D12 only fixed half of the freeze — the batching changed the
shape of the wait, not its length.

**Revisited the `content-visibility` rejection in D12, because the reason I gave
was half wrong.** I rejected it for an 11x spread in turn-group height
(median 2,631 px against a maximum of 29,754 px), which is true but is the spread
*between* groups. Inside a group the height is not a guess at all. Measured over
152 groups and 19,315 rows:

    plain rows   n=19,292   min 36   median 36   max 36
    turn heads   n=152      min 39   median 39   max 39
    bands        n=23       min 38   median 38   max 38
    group chrome n=152      min 0    median 0    max 12

Plain rows are min == max == 36 px across all 19,292 of them. The summary column
is nowrap + ellipsis, so it never wraps — "row height varies with content" was
never true, and I had assumed it was.

So I implemented the version that computes `contain-intrinsic-size` per group
from (rows, compaction bands), with the three heights read off the real DOM after
the first batch rather than written down as constants. It measures:

    total build        782 ms   (3,956 ms batched -> 5.1x; 979 ms for the CSS constant)
    self-calibrated    head 39.40 · row 36 · band 38.40
    groups sized       152 / 152
    predicted height   705,230   actual 705,529   error -0.04%
    document height    705,627 at the bottom, `auto` remembering rendered groups

Verified in the browser: scrolled to the bottom of the 705,627 px document, rows
render with real geometry, and a deep record (turn 27, #2129) is clickable with the
inspector filling correctly.

**Rolled back, because of something I saw and could not attribute.** With a
record selected in 「显示全部」, after roughly three poll-driven rebuilds the
viewport went from turn 27 back to turn 1. I did not find the cause and did not
rule out that it predates the change. D12's poll deferral was designed for a
~4 s build; at 782 ms it almost never engages, so the table now rebuilds on
nearly every poll — every 2.5 s instead of every ~6.5 s — which makes a jump more
likely to be seen. That is inference, not a conclusion.

The reason for not shipping is not that the gain is too small. The gain is
measured. It is that the cost is not measured, and scroll anchoring is exactly
what the D9 work exists to protect. Being 5x slower is better than shipping an
unlocated regression on that path.

D12 now records the numbers, what was verified, what was not, and the first step
to finish it: instrument `restoreRowAnchor()` and confirm whether `anchorOffset()`
returns a real position or 0 for a row inside a skipped subtree — a skipped
subtree's rect reads as 0, not null, and 0 is a position like any other.

Section 7 item 1 is updated to say the option is measured and rolled back rather
than untried.

* docs(mmc-trajectory): 归因滚动跳转为既有缺陷 D14,解除 content-visibility 路的阻塞

上一轮回退逐组推导 contain-intrinsic-size 版(782 ms / 5.1×)的唯一理由,是一次
「视口从第 27 轮跳回第 1 轮」的滚动回归未能归因。本轮把它查清了。

控制实验先做:在**已发布代码**上(无 content-visibility、无探针)跑同一条路径,
视口同样跳回第 1 轮,而检视面板仍正确显示 #2780 · 第 29 轮。所以它与
content-visibility 无关。

给 renderLedger() 的捕获点与 restoreRowAnchor() 打点后的实测(19,626 行 / 155 轮 /
文档高 716,000 px / 视口 841×900):

  cap  ySave=715080 yReal=616 doc=1515    <- clear(ledger) 把文档压到 1515px,scrollY 被夹到 616
  rst  after=707051 delta=707056 yOut=708185   <- 从被夹住的位置出发,仍正确还原
  cap  ySave=715080 yReal=616 doc=1515
       (无 rst:这次建行被点击取代,restoreRowAnchor 从未执行)
  cap  ySave=5530  yReal=1065 doc=1965    <- 点击触发的 renderLedger,锚点已经错了
  rst  docYWas=5515 docYNow=5515          <- 忠实地还原到了一个错误的位置

restoreRowAnchor() 本身是对的,即使起点被夹到 616 / 2,051 也能算出正确 delta 落回
原位。真正的丢失点是 buildStep() 的提前 return:

  if (build.token !== ledgerBuildToken) return;

D12 给在途分批定的规则「就地退出、不碰 DOM」是对的,但退出时没人把滚动位置交还给
下一个建行,而下一个建行捕获锚点时读到的是已被 clear(ledger) 夹断的位置。一旦落到
顶,之后每轮都拿「第 1 轮」当锚点,自我维持。

触发面:任何在轮询建行途中直接调用 renderLedger() 的读者动作,最大的一处是
selectRecord()——点任意一行都会整表重建。「显示全部」下建行 3,956 ms、轮询 2.5 s,
所以点一行大概率落在建行途中。

对照组证明变量唯一:同一份代码、同一处点击,点行时没有建行在途,则 delta 0、
yIn = yOut = 105,494,位置纹丝不动。

记为 D14,并给出未实施的修法:把「读者想去哪」从「建行结束时算」提前成
「第一次捕获时记下,此后每次建行都转交」,一个写手一个字段。

同时更新 D12 与第 7 节未做清单:#1 的阻塞标记为已解除,新增 #1b 记录 D14。

代码与测试逐字节未动(302,384 bytes),32/32 守卫全绿。

* fix(mmc-trajectory): 滚动意图跨建行转交,点行不再把读者留在第 1 轮(D14)

D14 的机制:位置不是丢失,是被摧毁再重建,而摧毁与重建之间没有任何东西。

  clear(ledger) 之前   doc = 716,016 px   scrollY = 715,080
  clear(ledger) 之后   doc =    1,515 px   scrollY =     616   ← 文档塌了,浏览器夹断 scrollY
  建行 6 帧约 3,956 ms,期间 scrollY 停在被夹住的位置
  buildStep() 收尾     restoreRowAnchor() 把位置算回来

restoreRowAnchor() 本身是对的,即使起点被夹到 616 / 2,051 也能算出正确 delta 落回
原位。丢失发生在 buildStep() 的提前 return:

  if (build.token !== ledgerBuildToken) return;

D12 给在途分批定的规则「就地退出、不碰 DOM」是对的,坏的是退出时没人把滚动位置交还
给下一个建行,而下一个建行捕获锚点时读到的是已被 clear(ledger) 夹断的位置。一旦落到
顶,之后每轮都拿「第 1 轮」当锚点,自我维持。

触发面:任何在轮询建行途中直接调用 renderLedger() 的读者动作,最大的一处是
selectRecord()——点任意一行都会整表重建。「显示全部」下建行 3,956 ms、轮询 2.5 s,
所以点一行大概率落在建行途中。

修法:把「读者想去哪」从「建行结束时算」提前成「第一次捕获时记下,此后每次建行转交」。
一个模块级字段 pendingAnchor,一个写手 forgetScrollAnchor()。

  - renderLedger() 只在 pendingAnchor 为空时捕获 {row, y},之后每一趟沿用。
  - restoreRowAnchor() 第一句就是 forgetScrollAnchor(),它是唯一以还原结束一链的地方。
  - 五条提前返回各调 forgetScrollAnchor():它们把账本换成状态块,没有可锚的行,
    丢弃而不是还原,否则会把针对旧会话或旧筛选测出来的位置套到另一个上面。

顺带修掉的一处(是上面那条的前提,不是顺手):ledgerBuildToken += 1 从 applyFilterScope()
那一侧移到 clear(ledger) 之上。四条提前返回都在自增之前返回,它们同样清空账本却什么都
不退休,于是在途建行会继续往骨架屏里追加轮组,随后还会拿一张已不存在的表的锚点去移动
页面。现在自增在清空之前,五条路径走同一句。

口径不变:显示全部仍是全部,窗口条读数不变,选中语义不变。

守卫:
  - 新增 tests/verify-anchor-carry.mjs(10 条断言)。捕获那三句是跑出来的——
    firstVisibleRow() 与 window.scrollY 换成返回另一个位置的桩,一个每次重读的版本会给出
    桩的答案而不是预设的答案;消费者按条数点,clear(ledger) 到 var groups 之间的 return;
    必须与 forgetScrollAnchor() 一一对应。
  - 新增 tests/mutate-anchor-carry.py,6 条变异全捕。
  - mutate-ledger-batch.py 原有两条变异锚在 ledgerBuildToken += 1 后面那一行,自增移动后
    失效——已重新锚定到 clear(ledger);,一条未删,并新增一条「把自增放回提前返回之下」
    忠实复现本缺陷。
  - verify-ledger-batch.mjs 从「数一次自增」加强为「数自增与 clear() 之间的 return;」,
    原来那条对四条提前返回完全失明。

浏览器实测(本机,19,991 条 / 156 轮 / 视口 841×900):显示全部 → 滚到第 133 轮 #17188
→ 点一行 → 等重建。建行途中视口短暂塌到第 51 轮(预期内),建行收尾后回到第 134 轮
#17207,正是点行前那一行;再点一行仍在第 134 轮 #17215。修复前同一操作把读者永久留在
第 1 轮。

34/34 守卫全绿,npm run validate 报 plugins/avatasia/mmc-trajectory: OK。
index.html 纯 LF,305,391 bytes。

* perf(mmc-trajectory): 逐轮组推导 contain-intrinsic-size,「显示全部」建行 3,437 → 817 ms(D15)

D12 把等待的形状改了(进度可见、分批交还主线程、在途可打断),等待的长度没变。
本条改长度。

做法:.turn-group 的 content-visibility: auto 由账本容器上的一个 class 打开,不是
无条件打开;class 只在第一批建完、并且从真实 DOM 量出行高/带高/头高之后才加上——
写死的常量就是猜别人屏幕的字体与缩放,所以不写死,用中位数不用均值。之后每个轮组
在建行那一刻按 (行数, 压缩带数) 算自己的 contain-intrinsic-size,格式带 auto 前缀,
让浏览器优先采用它已经渲染过一次的真实尺寸,来回滚动不累积漂移。

口径不变:「显示全部」仍然是全部,窗口条读数不变,选中语义不变,粘性偏移不变。
syncStickyOffsets() 仍是 buildStep() 的最后一句。

实测(同一会话内的 A/B,两臂都在同一浏览器、同一份数据上量):
  基线(class 不加)   3,437 ms   159 组   20,246 行
  逐组推导(class 加)   817 ms   159 组   20,182 行   文档高误差 +0.07%

D12 当初挡下这条路的那个疑问已有实测答案:content-visibility: auto 跳过的子树里,
行仍然有真实布局盒——离视口 733,916 px 的行报出 top: -733915.94, w: 776, h: 36,
不是 0 也不是空矩形。于是 anchorOffset() 在 cv 之下成立,D14 的锚点没有被破坏:
顶部、深处(640,165)、近底部(740,165)三个位置跨十几趟重建,preY 与 postY 逐
像素相同,期间会话从 20,552 长到 20,573 行;深处点一行之后屏上仍是同一批行。

已知未解释项(已在 docs/LAYERS.md 记为待复现,不写成缺陷):某一趟近底部的重建
postY 比 preY 高了 77,226 px 而屏上行未变,之后同一深度未能复现。

守卫:verify-intrinsic-size.mjs(14 条)+ mutate-intrinsic-size.py(9 条变异全捕)。
另重新锚定 mutate-ledger-batch.py 里一条因 build 字面量新增字段而失效的变异。

* test(mmc-trajectory): 声明 R6 的两处跨层配对,Stage 3 完成(未做清单 #5)

R6 允许控件所在层与它写入的层不同,条件是这对关系被写下来。此前它只存在于
docs/LAYERS.md 的一句话里,没有任何断言,于是改位置或改归属时没有一处会先红。

verify-cross-layer.mjs(12 条)+ mutate-cross-layer.py(6 条变异全捕)守的是配对,
不是整洁:DOM 父节点说这个控件被呈现成哪一层,赋值说它写哪一层,改任何一半而不
声明,配对就不成立。

  声明 1  #window-bar 在 L5 面板内、写 L3(只写模块级 ledgerWindow / ledgerProgress)
  声明 2  #overview 持有写 L2 字段 state.overviewSelection 的控件

六条变异分别对应六条具名断言,守卫必须按名字报出:把 window-bar 挪出面板、给它加
一个 state. 赋值、给进度写者加一个、把 state.overviewSelection 塞进窗口条、让刷选
落点不再写该字段、让 L2 入口不再读它。

第 6 条变异重锚过一次:var selection = state.overviewSelection; 在
renderOverviewSelection() 里也有一处,锚在 visibleEntries() 的函数头上,否则命中
的是 L3 的那个读者,而 L2 入口会保持完好、变异存活。变异不删,重锚。

本条刻意不覆盖 R5,而 R5 没有闭合:state.overviewSelection 今天有 5 个写点
(clearOverviewSelection / setActualDuration / onOverviewPointerMove /
onOverviewPointerUp / resetBaseFilters),没有一个是 R5 要求的那个单一 setter。
断言「已经收敛」是一条过不了的守卫,断言「当前是 5 个」是一条恒真的守卫,两条都
是错的写法,所以这个缺口留在文档里而不是被断言盖住。收它属于 D4。

同时把两项设计写进 docs/LAYERS.md,都标为「设计已完成,等指示」,未写实现代码:
按 AGENTS.md 的硬约束,四个问题的答案落文档之前不写实现。

  Stage 2(异常轴建模,#4)读点已数清:anomalyRules 4 写 13 读、multiAnomaly
  3 写 2 读、anomalyLast 7 写 1 读。R2 单一入口成立——flattenTurns() 产出
  {turn, record},所以两个入口拿到的本来就是同一个 turn 对象。对外口径不变,
  但 state 形状变,verify-anomaly.mjs(53 KB)与 mutate-anomaly.py(29 KB)里
  守形状的断言要逐条改写、守行为的原样保留,一条都不删。建议分三次独立提交。

  Stage 4(增量协议 ?since=,#2)调研结论改变了收益估计:payload 只有 session /
  stats / turns 三个键,41 MB 全在 turns,而 stats 是累加器必须全量重算——所以
  ?since= 省的是传输与客户端 JSON.parse,不是服务端 CPU。游标选 record.index。
  这是本轮唯一一处真口径变更(applyPayload 从整体替换变追加合并),读点已列全,
  写侧只有 1 处。

产品代码零改动:index.html 仍为 310,348 bytes,套件 36 → 38 条全绿。

* refactor(mmc-trajectory): 异常轴收成一个字段 state.anomaly {mode, rules, last}(Stage 2-a)

Stage 2 三步里的第一步:先让状态是一个模型,再谈入口(D5 / LAYERS.md)。

原来 axis 上有三个顶层字段 —— anomalyRules、multiAnomaly、anomalyLast。
「有没有规则开着」这一个判断要从三个地方重新推导,于是「关掉其中一个而
没关另外两个」读起来不像 bug、像另一个状态,它们之间的协调只能活在调用
顺序里,因为没有共同的模型可以住。现在是一个字段,rules / mode / last 三
个读点都在同一个对象上。

mode 用 'single' | 'multi' 字符串而不是布尔:每个读都是一次显式比较,
而不是 `multiAnomaly || !multiAnomaly`。`!state.multiAnomaly` 那种写法读
起来像「不是多选」,而它在 单选 与 未知 之间是同义的。

清零走工厂而不是共享常量:defaultAnomaly() 每次给一个新对象。一个被两处
共享的常量就是同一个双写者缺陷换了个位置。

零产品行为变化(这是 2-a 的口径):
- 账本读点 2 处、strip 读点 1 处、按钮读点 2 处 —— 全部原样改写
- 合计口径不动:anomalyCounts() 仍走自己的计数通道
- isFiltering() 仍只看 rules.length —— mode 不是 filter,勾上 多选 不该让
  「清除筛选」变成可点

新增 tests/verify-anomaly-model.mjs(15 条)守这个模型本身,刻意不守
「已收敛」—— 那个断言现在过不了,写成「当前 N 个」又恒真。守的是可以被
证伪的四条:一个字段、两条重建路径、一个工厂、mode 不是布尔。

mutate-anomaly-model.py:7 条变异,覆盖新建点被绕过、工厂被共享对象顶替、
mode 被写成布尔、mode 泄漏进 isFiltering。

重锚(不是新增断言,是把旧断言的锚点从旧字段名换到新字段名):
- verify-anomaly.mjs:fixture 声明合并成 anomaly 对象;19 处 multiAnomaly
  的读写换成 mode 的字符串比较;10 处形状断言改锚
- mutate-anomaly.py:20 条变异的锚点重锚。两条变异(strip 分支前置于另一
  个轴、单击关掉已开的规则)在重锚过程中 mutant 侧一度被写成和 old 侧等价
  —— 那是空变异,跑出来是「NOT CAUGHT」而不是失败。已改回。

39/39 绿(原 37/39)。index.html 310,348 → 311,217 bytes,纯 LF。

* refactor(mmc-trajectory): 可见性收成一个入口 recordVisible(record, turn, selection)(Stage 2-b)

R2 闭合。原来「这条记录在不在屏上」有两个函数回答:visibleEntries() 和
spanPassesFilter(),各自持有完整判断。这不是整洁问题——本页已经因为两份
拷贝漂移过一次:总览条画出账本拒绝渲染的跨度,读起来是「筛选了但什么都没
变」而不是 bug(D 类缺陷的结构来源)。

三处判断,逐条对着代码:

1. 笔刷是参数,不是 state 读。两个入口对笔刷的处理本来就不一样:账本用,
   总览条不用。总览条必须不用——笔刷画在它上面,一个把自己正在被拖选的记录
   藏起来的条没法用来拖选。写进签名是为了让下一个人不会「顺手修正」它。
   断言守的是 recordVisible() 体内不出现 state.overviewSelection。

2. turn 是参数。flattenTurns() 产出 {turn, record},账本拿得到;总览条只拿
   到裸 record 所以要从 turnById 查回。两者本来就是同一个对象。断言守的是
   账本不自己查——它手上已经有那个对象,再查一次是同一个对象换个来源,
   将来两边会长歪。

3. 互斥由 return 表达,不由 else 表达。「二选一不是叠加」这条断言原来只读
   `} else if (!passesBaseFilter`,守的是 else 的存在。新的断言要求异常分支
   return,且基础判断写在它之后——一个只判不回落的分支就是叠加,只是拼法
   相反。

anomalyContext() 随之删除:它只是为了把 entry 适配成 (record, turn),而
recordVisible() 直接收这两个。passesBaseFilter() 删除:唯一调用方就是
visibleEntries()。

新增 3 条断言 + 3 条变异(70 → 73)。其中最强的一条是「总览条体内一个谓词
都没有」——断言两个调用方都不可持有判断,而不是都应用了异常。适配参数形状
不是第二意见,再回答一次才是。

变异重锚(不是删除):
- 「总览条不应用 异常」→ 现在只有一个谓词持有者,改锚到 recordVisible(),
  名字跟着改(仍破同一件事:轴不生效)
- 「账本不应用 异常」→ 改锚到账本的调用点,整条替换为不过滤
- 「两个轴叠加而非二选一」→ 原来是去掉 else,现在去掉 return
- 「模式读反」→ visibleEntries 里的 anomalyMode 没了,改锚到 recordVisible
  的签名 + 第一行(`> 0` 在 recordVisible 与 applyFilterScope 各出现一次,
  锚必须带签名才能唯一定位)

verify-time-range.mjs 的三条断言同步改写。它不在 run.mjs 的默认集合里(需要
起服务 + 多代会话),已对着 55344 手跑:149 断言全过。

index.html 311,217 → 311,482 bytes,纯 LF。39/39 绿(默认与 --real),
73 变异全捕,validate OK。

过程事故:移除 anomalyContext() 时探针从签名向前回溯找 /**,跨过空行把刚插入
的 recordVisible() 一起删了,脚本仍报 ok 并写盘。由 fnSource 抽不出
recordVisible 暴露。已从 fork 复位,回溯改为「注释紧贴签名才吸收」,并让探针
打印删了多少行。空变异同理:mutation control 只报 probe still passed,不会
告诉你它什么也没测。

* refactor(mmc-trajectory): 删掉 D2 那三句互相矛盾的设计声明,并补上它自己的变异控制(Stage 2-c 注释半)

D2:state 里曾有两句话各自宣称自己在描述「筛选是什么」——
「The type chips are the only filter」与「This is the one filter the KPI
follows」——两个模型不兼容,而没有一层裁决哪句作废。两句各自都对,都在描述
一个更早的页面。

三处处置:
- KIND_ORDER 上方:删掉独占声明,保留「一个数组、一个写者」那半,并**列出另外
  三个也在收窄表格的条件**(异常轴替换它、区间按墙钟裁、笔刷按总览裁)。
  删掉独占声明却不说清剩下的是什么,等于把一句假话换成一句空话。
- kinds: 字段上方:整句删掉,保留后面关于折叠的段落——那段仍然成立。
- rangeFrom 字段上方:加限定词 →「Of everything on this layer, the KPI follows
  the range and nothing else」,并写明异常轴开着时 recordVisible() 一个都不查。

**这句话本身被守了**(verify-anomaly-model.mjs 15 → 17 条):index.html 里
"the only filter" 与 "the one filter" 各必须出现 0 次,而带限定词的那句仍在
README.md。守一段散文看起来奇怪,但这是本模型唯一一处改错了也不会有任何运行
时报错的地方——而一句与架构相矛盾的注释,不因为它是注释就不算假话。两条变异
分别把它插回 KIND_ORDER 上方与 kinds: 字段上方。

写入替换注释时踩了一个自指的坑:第一版把被删的原话原样引述在新注释里,于是
「零次出现」这条断言被自己的解释文本触发。改写为「claim exclusivity … with no
qualifier」。

**`resetBaseFilters()` 变一次赋值那一半,本轮显式不做。** 要「一次赋值」就得把
基础轴收成 state.base + 工厂,与 2-a 对异常轴的做法对称。不做的理由是它越界:
Stage 2 自己的「归层」答案是「不改 L1/L3/L4/L5/L6 任何一层」,而
overviewSelection 是 L3→L2 的回写(R5 有 5 个写点未闭合),收它就改了 L3
声明的形状;代价约 40 个站点横跨 L3/L4/L5,还要改写 verify-cross-layer.mjs
的断言。已作为未做清单第 9 行记录,并注明「要做的话四问要重答」。

**顺带修掉 2-a 的一个漏**:verify-anomaly-model.mjs 的文件头写着
「mutate-anomaly-model.py breaks each rule below」,而那个文件当时并不存在。
树里一句没有任何东西在检查的声明,正是这条守卫本身要抓的缺陷。现补上,16 条
变异全捕——其中 5 条初版写错,值得一提:三条只加了不带 `state.` 前缀的键,那
不是「第二个答案」只是一个死键;一条改了条件却没删赋值;一条把写者改成自赋值,
写者计数根本没变。变异必须真的改变被计数的东西,锚点命中不等于变异有效。

index.html 311,482 → 311,945 bytes,纯 LF。40/40 绿(默认与 --real),
73 + 16 变异全捕,validate OK。

* feat(mmc-trajectory): 增量协议 ?since=,会话增长时只传尾部(Stage 4)

本轮唯一一处真口径变更:applyPayload 从整体替换改为追加合并。

请求多一个可选游标 ?since=<整数 ≥ 0>;响应多一个顶层键 delta。**有 delta 就
合并,没有就整体替换**——这个标记是判定的唯一依据,客户端因此不需要猜服务端
这次发的是哪种。游标缺失 / 空 / 非整数 / 负数都不是错误,一律当没有游标返回
整包:什么都还没有的页面需要的就是整包,把首轮轮询变成 400 是比发 41 MB 更
差的答案。

三个判断:

1. stats 整包来,因为它是个累加器——里面每个计数都是遍历全部行累加出来的,
   尾部无法被「加」到它上面,要那样就得由客户端重新推导服务端的算术。session
   同理:几百字节,而用上一份回答它会让每个 payload 依赖一份服务端并不保留
   的历史。
2. 轮整轮携带,只切 records。这重复了页面已有字段,是刻意的:对话进行中
   closed 会翻转,而 无答复 由它判定——发残缺的轮,页面就会对唯一那个还在被
   写入的轮持有过期的 closed。
3. 游标超过末尾返回 null(即整包)。那是页面跑到服务端前面、也就是从服务端看
   「切了一代」的样子;从那儿切出的尾部是空的,而空尾部与「没有新东西」不
   可区分。

服务端只在冷构建路径切尾部。缓存命中那条路径不处理 ?since= 且不是遗漏:带
游标的页面一定也带着它对应的校验器,所以带着游标走到这一行意味着会话变了。
而从缓存 payload 里切尾部还要把那个对象图留在内存里,这个缓存刻意不留。

客户端游标与校验器同一个条件、同样绑定 cache key(state.since / sinceFor),
手动刷新同时丢掉两者——它是在向服务端要真相,尾部不是真相。合并以
body.delta && state.payload 为前提,缺一不可。mergeTurnRecords 按 record.index
取并集而不是追加,所以同一个尾部重放两次什么也不会变:重试和重叠轮询都会发
生,只在顺利路径上正确的协议会在第一次重放时丢记录。

**省的是传输与客户端 JSON.parse,不是服务端 CPU。** 实测(冷缓存,同一会话):

  整包        43,159,724 bytes   服务端墙钟 1,124 ms
  ?since=21584    19,663 bytes   服务端墙钟 1,124 ms   (0.046%)

服务端墙钟两次相同:它仍全量读、解析、构造、序列化(缓存条目与 /raw 都需要整
包 body)。这不是推算,是量出来的。

浏览器端:窗口条「共 21,680 条」与同一时刻 API 的 21,679 条同步,差 1 条正是
两次读取之间我自己产生的记录——合并既没丢也没重。console 零 error / 零 warn。
网络面板:无变化轮 304 约 30 ms,会话增长轮 200 约 640–710 ms(服务端那部分没
变,正如预期)。

**证据边界**:网络面板不返回响应体大小、也不返回 URL query,所以「那些 200 是
尾部」不是从面板读出来的,是冷缓存直连测出来的;面板只能证明轮询节奏与状态
码。304 在面板里显示 outcome: failed + ERR_ABORTED(304 无正文,页面在读正文
前就 return),这条路径本轮一行未改,但**我没有做改动前的 A/B 对照**——按推理
它早于本改动存在,没有实测证据。

守卫 verify-incremental.mjs 63 条 + mutate-incremental.py 16 条变异全捕(同时改
server.mjs 与 index.html 两个文件)。核心是往返等式 mergeDelta(整包, delta) ==
整包——不是「差不多」,是轮、closed、记录一次不多一次不少地回到原样,在 6 个
游标位置各跑一遍;其余断言都在回答这条等式为什么会不成立。其中一条是手算的
小合成用例,因为 fixture 证明不了:里面没有正在增长的轮。

两处过程事故记在 docs/LAYERS.md:
- 守卫自己崩过一次——变异让 find() 返回 undefined,守卫直接读属性。…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants