Commit graph

4137 commits

Author SHA1 Message Date
易良
2855149d47
fix(core): detect long verbatim repetition loops in content and reasoning streams (#9668)
* fix(core): detect long verbatim repetition loops in content and reasoning streams

The chunk-hash content loop rule only treats repeated 50-char chunks as a
loop when their occurrences cluster within 1.5 chunk lengths (75 chars), so
a verbatim-repeated unit longer than that (the ~300-char analysis block
chanted in issue #1775) never fires. Add a long-period rule: five equally
spaced occurrences of an identical chunk mark a candidate period, and the
spanned region is verified to be exactly periodic with that stride before
halting. Raise the content history window so long units stay observable.

Also route thought text into the content-repetition detectors when the
structured thought check does not fire: OpenAI-compatible providers stream
reasoning as thought parts that getResponseText filters out of Content
events, so chants in the thinking stage never reached the chunk-hash rules.

* fix(core): isolate reasoning deltas from the content channel's markdown state

Route thought-sourced text through an append-and-analyze-only entry point
instead of checkContentLoop. Reasoning text is raw chain-of-thought, never
rendered markdown: an unbalanced code fence in a thought used to flip the
shared inCodeBlock parity — which nothing clears mid-turn — silently
disabling visible-content chant detection for the rest of the turn, and
list/heading-shaped thought deltas reset the shared history, erasing
already-accumulated content evidence when a provider interleaves thought
and content parts.

* fix(core): grow the periodic-rule verified region with the repetition count

The long-period rule only inspected the last five occurrences, pinning the
verified region at 4 x stride + 50 chars: units of ~76-237 chars fell in a
gap between the clustered rule's 75-char bound and the 1000-char region
floor at any repetition count, and units of ~1 KB or more could never fit
five occurrences into the 4000-char history window at all. Extend the
candidate run backwards over the longest equally-spaced suffix of
occurrences so the verified region grows with the repetition count, and
once the history saturates accept a shorter run (>= 3 occurrences) when the
entire retained region is verified periodic back to the history start, so
earlier occurrences truncated out of the window cannot hide a chant. Also
correct the constants' comments describing the rule's domains.

* test(core): cover post-truncation chant detection after a long varied turn

Add the realistic #1775 shape that had no positive coverage: a long varied
turn filling the history window, then a ~700-char chant streamed as
misaligned deltas. Asserts detection at exactly the fifth in-window
occurrence, pinning MAX_HISTORY_LENGTH, truncateAndUpdate's index
adjustment, and the long-unit case together — a shrunken window would fire
early via the truncated-run path once the filler flushes, and a broken
index adjustment would never fire.

* fix(cli): widen chanting halt label to cover reasoning-stream repetitions

Reasoning-stream chants fire CHANTING_IDENTICAL_SENTENCES via
checkReasoningContentLoop, but getResponseText filters reasoning out of
visible output, so the headless label 'repeated the same sentence in its
output' sends users looking for a repetition that is never rendered.
Widen the label to 'output or reasoning' and add a headless-path
regression test asserting the wording.

* refactor(core): share the append/truncate/analyze tail across loop channels

checkReasoningContentLoop duplicated the streamContentHistory append,
truncateAndUpdate, analyzeContentChunksForLoop tail of checkContentLoop,
leaving the history contract in two copies that a future fix could let
drift. Extract the tail into appendToContentHistoryAndAnalyze and call
it from both entry points.

* perf(core): compare periodic regions in place instead of slicing history

isRegionPeriodicWithStride sliced up to ~4 KB of history per invocation.
Near-periodic chants fail verification repeatedly while their occurrence
runs persist, so once a run reaches length 5 the check fires on up to
every streamed character -- a probe measured ~136 MB of transient copies
over one 49k-char stream. Index the existing string directly instead;
comparison semantics are unchanged.

* fix(core): reset stream-content loop state on retry replays and model fallback

A replay (non-continuation) retry re-streams the failed attempt's
content and reasoning through the chunk detectors — the #7832
transport-replay gate admits thought-only cuts, and with deterministic
decoding the re-stream is verbatim. The Retry case in
addAndCheckHeuristicLoops cleared only the tool-call counters, so the
accumulated identical copies could fire CHANTING_IDENTICAL_SENTENCES
mid-way through an otherwise healthy attempt. Continuation retries
(isContinuation) keep the delivered text and append new output, so
their state stays. ModelFallback had no case at all: the fallback model
restarts from scratch, so mirror the replay resets for it. A genuine
chant simply re-accumulates after the restart.

* perf(core): defer content-history truncation with a hysteresis slack

Once streamContentHistory saturates, truncateAndUpdate walked the whole
contentStats map on every streamed event — Θ(window) entries in steady
state, since the stride-1 sliding window hashes every position
(~385 µs/event at window 4000 vs ~12 µs pre-saturation). With
high-frequency small reasoning deltas now routed through the path,
healthy long-thinking turns paid thousands of events of synchronous CPU.

Trim only when the length exceeds MAX_HISTORY_LENGTH by a
TRUNCATION_SLACK margin (1000 chars), slicing back to exactly
MAX_HISTORY_LENGTH, so the index-rebase walk is amortized over appended
chars. The change is behavior-neutral: the detection rules now always
operate on the logical window of the last MAX_HISTORY_LENGTH chars —
occurrences the window has passed are dropped at lookup (the exact set a
per-event trim would have removed) and the periodic rule's escape valve
verifies from the window start, i.e. exactly the content a fully-trimmed
history retains. Tests pin pre-change fire offsets across saturation and
multiple trims, plus the deferred-trim mechanics.

* feat(core): log a chanting-region excerpt on loop halt for debug

A reasoning-channel halt exits headless runs with empty stdout and only
the loop-type label on stderr; neither the LoopDetected event
(loop_type + prompt_id only), telemetry, nor any log carried an excerpt
of what repeated, leaving no way to tell a true repetition from a
detector misfire without instrumenting a repro.

Capture one period of the matched region (the span between the last two
occurrences, capped at 80 chars) when the chanting detector fires and
emit it through the config debug logger at the firing site. The
LoopDetected event contract is deliberately unchanged.

* fix(core): preserve subagent continuation retries

* test(core): cover plain subagent retry forwarding

* fix(core): omit plain retry continuation flag
2026-08-24 02:21:54 +00:00
nas
b2edb80a57
fix(cli): probe microphone permission on recording start, not voice warmup (#8912)
* fix(cli): probe microphone permission on recording start, not voice warmup

Voice warmup called recorder.microphoneStatus() as soon as the input
prompt mounted with voice dictation configured. On macOS an undetermined
TCC status maps to 'prompt', so every startup appended a "Voice dictation
needs microphone access" notice to the chat history, including for users
who never record.

warmupVoice now only preloads the recorder backend. The permission probe
and its 'denied'/'prompt' notices move to a checkMicrophonePermission
callback that useVoiceInput invokes from startRecording, so the notice
reaches only users who are actually trying to dictate.

The dedup ref moves up to Composer and reaches InputPrompt as an optional
prop, matching clipboardUnavailableShownRef. A per-instance ref reset on
every InputPrompt remount, which is what produced the duplicate notice.

Fixes #8877

* fix(cli): hold voice mic-permission dedup in AppContainer, not Composer

Dialogs (tool approvals, auth, settings) swap Composer out of the layout,
so a ref held in Composer reset on every dialog round trip and the notice
could repeat on the next recording. The ref now lives in AppContainer,
which owns dialogsVisible and never unmounts, and reaches InputPrompt
through uiState like mainControlsRef.

Also from review: delegate setupRecorder to setupRecorderWith in the
InputPrompt tests, cover the prompt->denied status transition (re-warns
as an error), and assert Composer forwards the session ref with stable
identity across input-active toggles.
2026-08-24 02:15:04 +00:00
Dragon
78eadd4bf1
feat(core): declare create_sub_session only under qwen serve (#9425)
* feat(core): declare create_sub_session only under qwen serve

create_sub_session needs the daemon bridge, which only exists under `qwen serve`, yet it was declared in every session. Interactive TUI and headless runs therefore carried a tool that can never succeed, polluting the model's action space and ToolSearch results. The tool is now registered by the ACP session at the same point it wires the sub-session spawner, so it exists exactly where it can work and nowhere else.

* fix(core): keep create_sub_session on registries built with a wired spawner

Dropping the unconditional registration also dropped the tool from every
registry rebuilt after the daemon session starts: sub-agent and override
registries are built through createToolRegistry with forSubAgent, and
copyDiscoveredToolsFrom carries discovered tools only, never built-ins.
Daemon sub-agents therefore lost the capability silently.

Restore the lazy registration but gate it on a sub-session spawner being
wired onto the Config, so interactive, headless and SDK runs still do not
advertise a tool that cannot work there, while daemon sub-agent and
override configs pick it up through prototype delegation. Going back
through the lazy path also restores the PermissionManager.isToolEnabled
gate for these registries.

Harden the negative test to assert on both registration entry points; a
regression that re-adds the tool eagerly never touches registerFactory,
so the previous assertion would have stayed green. Add a positive test
covering a subagent registry rebuilt after the spawner is wired.

* docs(core): align setSubSessionSpawner doc with the new gate

The setter's JSDoc still described the pre-PR behaviour — that leaving the
spawner unset makes the tool report itself as daemon-only. With the
spawner-gated registration the tool is never registered in interactive TUI
or headless, so nothing reports anything there, and the comment contradicted
the three sibling doc sites this PR already updated.

* fix(cli): permission-gate the daemon create_sub_session registration

The eager registration in the Session constructor called
ToolRegistry.registerTool() directly, which honors only `tools.disabled` —
so a daemon whose operator restricts `tools.core` or denies the whole tool
still advertised create_sub_session and failed every call with
EXECUTION_DENIED, exactly the "declared but unusable" pollution this change
set out to remove.

Registration now lives in an awaited helper that applies the same
PermissionManager.isToolEnabled() check the core-side gate in
createToolRegistry applies, and the daemon calls it once per session it
creates, after the Session has wired the spawner and before the session is
published. Also drops the unused CreateSubSessionParams public export.

* fix(cli): declare create_sub_session only on daemon-backed sessions, revealed to the model

Address the R4 review findings:

- Wire the sub-session spawner only when the daemon's QWEN_CODE_SERVE=1
  stamp is present. A standalone --acp session's peer is the editor,
  which answers the bridge's qwen/control/* ext methods with JSON-RPC
  -32601, so the tool was declared there but could never run. Gate
  registerCreateSubSessionTool on the spawner being wired so the tool
  exists exactly where it can execute.
- Reveal the deferred tool and refresh the declaration snapshot after
  registering: the registration lands after startChat() froze the chat's
  declarations, so without the reveal the model was never offered the
  tool for the session's first lifetime.
- Pin that newSession awaits the registration before the session is
  served, so the first prompt's declarations always include the tool.

* fix(core): pin the create_sub_session reveal across /clear resets

The reveal applied at daemon-session creation was permanently lost by the
first /clear whenever the deferred-tool startup preload did not fit its
all-or-nothing schema budget (or was disabled by a <= 0 / non-finite
operator threshold): resetChat() clears the revealed set, the preload
restores nothing, and registerCreateSubSessionTool never re-runs — the
tool silently dropped out of the declaration list for the rest of the
session.

Add ToolRegistry.pinDeferredToolReveal(): pinned reveals are session-setup
state (not ToolSearch discovery) and are re-applied by
clearRevealedDeferredTools() while the tool stays registered and deferred,
so the fresh session's startChat -> setTools() re-declares it. Pin
create_sub_session at registration.

* test(cli): pin create_sub_session registration on the permission-manager-enabled path

* docs(core): correct DAEMON_ONLY_MESSAGE reachability in create-sub-session header

Per wenshao's runtime verification (N2): the guard is reachable only for
a daemon session whose spawner was cleared mid-flight; in non-daemon
sessions the tool is absent from the registry, so a stale direct call
hits the registry-miss error before execute() is reached.
2026-08-24 02:07:23 +00:00
tlysanhuo
a369b4fac6
fix(cli): prevent input border overflow on resize (#8991)
* fix(cli): prevent input border overflow

* fix(cli): harden border width invariant
2026-08-24 02:07:12 +00:00
zhou2024NAU
9b27184903
fix(cli): normalize win32 drive-letter casing in MCP approval keys (#9779)
* fix(cli): normalize win32 drive-letter casing in MCP approval keys

Windows paths are case-insensitive, but the two entry points that produce a project root disagree on casing: the CLI stores process.cwd() as typed (D:\project) while IDE integrations pass VS Code's workspaceFolders[0].uri.fsPath with a lowercased drive letter (d:\project). normalizeProjectRoot() only resolved the path, so an approval recorded by the CLI was invisible to the IDE and the server showed as configured-but-pending.

Fold case on win32 following the existing getProjectHash()/sanitizeCwd() convention, and fold stored keys at load time so decisions written by older builds are not orphaned; duplicate-cased keys merge into one entry and are rewritten normalized on the next save.

Fixes #9775

* fix(cli): address review feedback on win32 MCP approval key folding

Fold only win32 absolute paths (drive-letter/UNC) at load time so foreign POSIX keys synced from a Linux machine round-trip verbatim (R1-4). When duplicate-cased keys collide, prefer the rejection over an approval: records carry no timestamps and file order does not track recency, so a stale approval can never re-enable a server the user rejected (R1-2). Reuse isApprovalRecordMap in setState instead of the inline duplicate predicate (R1-3). Make approve.test.ts persistedStatus() fold the lookup on win32 to match the stored key shape, so the suite passes on Windows runners whose temp path contains uppercase letters (R1-1). Add win32 regression tests: non-drive component casing, POSIX-key preservation, rejection-wins merge (R1-5).

* fix(cli): address round-2 review feedback on MCP approval key folding

Use a null-prototype merge target so a server named __proto__ keeps its decision when duplicate-cased project keys merge on win32 (R2-1). Skip non-record values during merge so a corrupt null record no longer throws and poisons the whole approvals file (R2-2). Pin the rejection-wins merge invariant across both key orders (R2-3) and add a legacy UNC-key fold case (R2-4).

* test(cli): cover null-record guard when merging case-collided win32 keys

The non-record-value guard in mergeApprovalRecords (added for R2-2) was only reachable on win32 via normalizeStoredProjectKeys and had no test exercising the merge path, so deleting it shipped green on every platform. Add a win32-gated case: two case-variant keys for one project where the later-iterated key holds a null record value. Assert the load succeeds with no errors and the valid decision under the other casing survives. Addresses the round-3 review suggestion.

* chore: re-trigger automatic review

---------

Co-authored-by: zhou2024NAU <zhou2024NAU@users.noreply.github.com>
2026-08-24 01:33:03 +00:00
callmeYe
eea98f3b04
refactor(cli): extract ACP skill management (#8865)
* refactor(cli): extract ACP skill management

* test(cli): cover ACP skill safety guards

* fix(cli): harden ACP skill mutation guards

* fix(cli): handle ACP skill frontmatter variants

* test(cli): deduplicate ACP skill fixtures

* fix(cli): handle multiline Skill enablement fields

* fix(cli): recognize escaped Skill enablement keys

* refactor(cli): restore ACP skill extraction scope

Restore the three post-review files to the initial extraction commit. The removed changes addressed pre-existing Skill behavior and test coverage rather than regressions caused by the module split. Latest origin/main changes only unrelated ACP agent sections, so no extracted Skill logic needs to be carried forward.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-08-24 01:18:22 +00:00
qwen-code-dev-bot
ed5c56d840
fix(core): clear tool display list before awaiting completion callback (#9602)
* fix(core): clear tool display list before awaiting completion callback

The TUI completion callback commits the finalized tool_group to history
and then awaits the tool-result continuation, which since #9121 spans
the entire next model turn. The display-list clear was chained after
that callback in the finally block, so the completed group stayed in
the live pending list - pinned at the bottom of the virtualized list -
until the next tool call arrived or the loop ended (#9420, regression
in v0.21.13; v0.21.12's fire-and-forget submission cleared same-frame).

Notify observers that the display list is empty immediately before
invoking the completion callback (no await in between, so the clear and
the history commit land in the same React render); the finally-block
notify remains as the error-path fallback. Adds a regression test that
fails on main.

* test(core): strengthen finally-notify assertion in display-clear regression test (#9602)

* fix(cli): hold in-flight flags across the tool completion callback window (#9602)

---------

Co-authored-by: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com>
2026-08-24 01:17:38 +00:00
Shaojin Wen
3a1f86d805
feat(review): give verifiers a do-not-refute list and a constructible rejection bar (#9799)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* feat(review): give verifiers a do-not-refute list and a constructible rejection bar

Step 4's verifier brief already floors uncertain Criticals at low
confidence instead of rejection, but it never names the states in
which "too speculative / depends on runtime state" is not a valid
rejection. The finder side carries the recall rule (do not silently
drop a candidate); the verifier side lacked its counterpart, so
real-but-uncertain findings could die in Step 4 on a plausibility
vote instead of surfacing under "Needs Human Review".

Close the same leak on the verifier side:

- Rejection is now defined as direct counter-evidence constructible
  from the code — one of four shapes: factually wrong (quote the
  misread line), provably impossible (type/constant/invariant,
  shown), already handled in this diff (cite the guard and show it
  covers the trigger), or pure style / an Exclusion Criterion. A
  rejection constructing none of them downgrades to confirmed (low
  confidence) instead of dropping.
- A third masquerading state joins "I could not verify it" and "its
  evidence is somewhere I did not look": "it is too speculative". A
  finding whose failure scenario names a realistic state the code
  does not exclude is PLAUSIBLE by default — concurrency races,
  nil/undefined on a rare-but-reachable path, falsy zeros treated as
  missing, off-by-one on a boundary the code does not exclude, retry
  storms and partial failures, patterns that lost an anchor.

SKILL.md's Step 4 summary and the user-facing code-review docs are
synced to the new semantics. The pinning test asserts every shape,
every ground, and the downgrade consequence — a mutation flipping the
consequence into "reject" survived the subject-only assertion, so the
consequence clause is pinned too.

Fixes #9789

* fix(review): sync the rejection-bar summaries with the brief's four grounds (#9799)

* fix(review): sync the plausible-by-default wording and re-head the probe option (#9799)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-08-23 23:33:12 +00:00
Isaac Hernández
fd9c452dc8
fix(auth): let Vertex AI authenticate with Application Default Credentials (#9017)
* fix(auth): let Vertex AI authenticate with Application Default Credentials

Vertex AI auth required an API key, so an ADC or service account setup
could not start. Supplying a placeholder to satisfy the check made it
worse: an explicitly passed key switches the Google SDK to Vertex
Express mode, which clears the project and location and rejects the
request with "API keys are not supported by this API".

Treat a configured GOOGLE_CLOUD_PROJECT as sufficient credentials for
the vertex-ai auth type, in both the CLI pre-flight check and the core
model config validation, and leave the API key absent so the SDK
resolves ADC itself. The missing-credentials errors now mention the
keyless path instead of pointing only at envKey.

Fixes #9016

* fix(auth): select Vertex mode explicitly and keep declared key vars authoritative

Review follow-ups on the Vertex ADC change.

Vertex mode no longer depends on the GOOGLE_GENAI_USE_VERTEXAI side effect.
Only the CLI pre-flight check writes that variable, and the startup call to it
sits under the sandbox branch, so a plain interactive or ACP session built a
client pointed at the Gemini API endpoint instead of Vertex. The flag is now
derived from the auth type at construction, and left untouched for the other
auth types so the SDK keeps its own environment fallback there.

An entry that declares its own key variable no longer falls through to ADC when
that variable is unset. It keeps failing on the declared variable, so a secret
that failed to inject cannot silently authenticate as a different principal.
The keyless hint is suppressed for those entries as well, since it would be
advice that cannot work.

The ACP pre-flight cell reports an indeterminate state for a keyless Vertex
setup rather than a confirmed token: a configured project is routing
configuration, not evidence that a credential resolves. All three gates now
share one definition of a configured project, so whitespace is handled the same
way everywhere, and the CLI missing-key message carries the same keyless hint as
the core errors.

Docs corrected on two counts: the environment-only row now says a keyless setup
must select the auth type explicitly, since it is not inferred from the project
alone, and the provider note names every key source the resolver folds in.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-08-23 19:27:16 +00:00
callmeYe
c2d63fbe58
fix(web-shell): show reasoning effort before session creation (#9599)
* fix(web-shell): show reasoning effort before session creation

* fix(web-shell): harden reasoning preview lifecycle

* chore(desktop): refresh frozen bun lockfile

* fix(web-shell): restore reasoning preview after session clear

* test(webui): pin session-clear model restoration on all reset paths (#9599)

Witness the four back-to-welcome reset handlers' models re-projection
(session_closed, stream auth failure, terminal stream error, heartbeat
clear) with mutation-visible assertions: each test attaches a session
whose live context displaces the provider models, then verifies the
workspace reasoning preview returns after the reset. Also pin the
providers-absent fallback in getConnectionAfterSessionClear so older
daemons keep the pre-clear model list.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-23 19:11:19 +00:00
Nothing Chan
d52bb4d678
fix(config): allow prompt hooks in settings schema (#8779)
* fix(config): allow prompt hooks in settings schema (#8752)

* fix(cli): make prompt hook schema test type-safe

* test(cli): preserve hook type schema coverage

---------

Co-authored-by: nothing <nothing@U-DQY4PXFJ-0222.local>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-08-23 18:39:05 +00:00
samuelhsin
4ddbf227e8
feat(mcp): add MCP 2026 core and WebShell Apps host (#8992)
* feat(mcp): add 2026 protocol negotiation

* feat(mcp): render MCP Apps in WebShell

* fix(mcp): keep legacy tool discovery lenient

* fix(mcp): keep Apps HTML out of TUI and honor tool visibility

TUI and history compaction dumped mcp_app HTML as JSON, and discoverTools registered app-only tools for the model.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): stabilize AppBridge lifetime and close sandbox CSP gaps

Theme toggles and transcript reseeds were tearing down MCP Apps; the host CSP also allowed any loopback port and form posts bypassed connect-src.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): list under-declared modern MCP capabilities over the wire

v2 typed helpers return [] without a request when a capability is omitted.
Use them only when the server declared the capability, and keep Apps
unmounted in collapsed tool rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep Apps sandbox reachable and list past 64 pages

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): reject empty compacted html and keep MCP Apps expanded in multi-tool groups

Fixes R3-1 and R3-2 review comments:

R3-1: getMcpAppDisplay now rejects empty html strings (from compaction)
so session replay shows fallbackText instead of mounting an empty iframe.

R3-2: ToolGroup now checks for MCP apps across all tools (not just
singleTool), auto-expands when any tool has an MCP app, and keeps
MCP app rows expanded (summaryOnly=false, forceExpanded=true) even
when adjacent tool calls are merged into the group.

* feat(web-shell): fold thinking into the compact-mode tool summary (#9148)

Compact mode used to drop thinking messages entirely, so a running turn
gave no indication of the thinking step. Keep the thoughts and aggregate
them with the adjacent tools into one summary: a streaming thought reads
"Thinking…" with the running shimmer, and a completed thought settles into
a click-to-expand row in its original interleaved position. The translate
action is preserved on both the thinking block and the folded thought
rows, and the merged group gets a synthetic id so its expanded state never
leaks into non-compact mode.

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* fix(mcp): address app discovery and sandbox regressions

* fix(web-shell): keep MCP apps expanded in compact summaries

* Revert "feat(web-shell): fold thinking into the compact-mode tool summary (#9148)"

This reverts commit ab2eebc5d36f17a51ce94e423db5745dcbb273fe.

* fix(web-shell): render compacted MCP App fallback and teardown before unload

Compacted history keeps type:mcp_app with empty html; show fallbackText instead of a blank sandbox, and wait for ui/resource-teardown before unloading the iframe.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): bound the discover probe and raise the daemon bundle cap

Silent legacy stdio servers inherited the 10-minute request timeout for server/discover. Cap the probe at 5s so fallback fits the discovery window, and raise the browser bundle budget after the main merge overflowed CI by 47 bytes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): skip version-negotiation probe on remote transports

SDK v2 rejects HTTP server/discover timeouts without falling back to initialize, and the 5s probe consumed the entire remote discovery window.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): keep MCP App iframe src across deferred teardown

Deferred unload() was clearing src on the live iframe after a remount, so the new AppBridge never saw sandbox-proxy-ready.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): honor listing-level MCP App CSP and permissions

registerAppResource puts ui.csp/permissions on resources/list, and resources/read does not merge that metadata into content entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): reuse session client for list and emit app fallback text

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep mcp list and IPv6 sandbox CSP valid

Give qwen mcp list leftover handshake budget after the 5s discover probe, and stop emitting invalid [::1] CSP origins.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep modern list and short discovery budgets working

Drop the era-illegal ping after mcp list connect, shrink the stdio discover probe to the discovery window, and document that remotes stay on legacy initialize until the SDK can fall back.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep the 2026 slice free of review-only extras

Drop the global tools/list page cap, generated companion notices, and the review screenshot so this PR stays on stdio 2026 plus the WebShell Apps host.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): restore generated companion notices after the SDK v2 bump

CI regenerates NOTICES.txt from the lockfile; the file has to ship with the new MCP client dependencies.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): isolate the Apps proxy from WebShell storage

Drop allow-same-origin on the outer sandbox iframe so a default localhost daemon cannot read the WebShell session token.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): harden fallback and app sandbox

* fix(core): preserve large and app-only MCP catalogs

* fix(mcp): preserve legacy negotiation compatibility

* fix(mcp): default stdio negotiation to legacy

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: ytahdn <1294726970@qq.com>
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: YungSen Hsin <yungsenhsin@U-G0HXNQM1-2052.local>
2026-08-23 18:34:30 +00:00
易良
56db17bd4c
refactor(cli): enforce utils leaf-layer dependency direction (#9146) (#9737)
* refactor(cli): enforce utils leaf-layer dependency direction (#9146)

Move domain-coupled modules out of packages/cli/src/utils into the
directories that own them: config/ (dialogScopeUtils, settingsUtils),
i18n/ (languageUtils), ui/ (handleAutoUpdate, standalone-update,
systemInfo, systemInfoFields, update-relaunch, commands, doctorChecks),
nonInteractive/ (nonInteractiveHelpers, chat-recording-failure,
tool-result-boundary-diagnostics, permission-suggestions), serve/
(sandbox), services/housekeeping/ (scheduler, non-interactive-scheduler),
and commands/review/ (findings).

Extract the generic normalizePartList helper into
utils/normalize-part-list.ts so utils consumers keep importing downward,
and move the MergeStrategy enum into utils/deepMerge.ts (its owner).

Add an eslint architecture rule (no-utils-upward-import) that forbids
value imports from utils/ back up into a domain directory. Type-only
imports stay exempt: they are erased at compile time and cannot create a
runtime cycle (Settings in modelConfigUtils, CommandContext in
sessionPaths).

No behavior change: typecheck, build, and the affected unit tests pass.

* fix: use Qwen Team 2026 license header on new files (#9146)

* chore: refresh stale utils/ path references after leaf-layer move (#9146)

* docs: reconcile no-utils-upward-import header with the allowed type-only set (#9146)

* fix(cli): allowlist sandbox process.env accesses after leaf-layer move (#9146)

* chore(ci): re-record qwen-autofix.yml size baseline after #9677 (#9146)

#9677 recorded qwen-autofix.yml at 392111 bytes while the file it
committed was already 397656, so every PR that merged main after it
tripped the growth ratchet. Re-record the actual size; the file itself
is unchanged by this PR.

* fix(review): drop the stale utils/findings.ts digest root after the leaf-layer move (#9146)

The #9146 move returned findings.ts to commands/review/, but the digest
root lists merged from main still pinned it under utils/, where the file
no longer exists — the absent root darkened every review's staleness
check and failed review-source-digest.test.ts. Drop the stale file-shaped
root from both digest copies and their pins; the commands/review/
directory root covers the validator at its new home, and the two utils
helpers keep their file-shaped roots.

* fix(review): colocate seatbelt profiles with the sandbox module (#9146)

* fix(review): exempt inline type-only specifiers from the utils upward-import rule (#9146)

* fix(review): report upward inline type-specifier imports under verbatimModuleSyntax (#9146)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): pin mixed-specifier and zero-specifier upward imports in the utils rule (#9146)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): anchor the nested-checkout utils rule fixture on the last marker (#9146)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): pin that the utils/findings.ts digest root stays removed (#9146)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(review): reword stale-bundle SCOPE header to the post-move helper shape (#9146)

* test(review): drop the pre-move utils/findings.ts from the skill-parity fixture (#9146)

* test(serve): derive the seatbelt colocation tripwire from BUILTIN_SEATBELT_PROFILES (#9146)

* fix(architecture): fail closed on computed dynamic imports in the utils leaf rule (#9146)

* fix(cli): point settings.test.ts at the post-move settingsUtils path (#9146)

main updated settings.test.ts after this branch moved settingsUtils.ts
from utils/ into config/, and the merge kept main's old import
specifier, which vite fails to resolve. Repoint it at ./settingsUtils.js;
every other consumer already uses the new path.

* fix(cli): close utils boundary review gaps

* test(cli): cover utils boundary allow paths

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-23 14:41:49 +00:00
Shaojin Wen
f877fb3525
feat(review): add the persistently-critical convergence advisory (land-with-residual-risk) (#9526)
* feat(review): add the persistently-critical convergence advisory

The severity floor converges a healthy loop — Suggestions stop posting and
the volume falls to the Criticals, then to zero as those get fixed. But a
loop whose Criticals never clear — the security-sensitive PR under
adversarial review — posts Criticals every round forever: the floor engages,
the Suggestions stop, and the volume flatlines at the Critical count instead
of falling. Nothing before this said so.

This adds the shape detector and its ONE recommendation:

- lib/convergence.ts — `convergenceAssessment` computes one fact from the
  carried telemetry (Criticals stood in the previous round's work-list AND
  stand again this round, with the two-round posting window present and not
  shrinking) and, when it fires, returns the `land-with-residual-risk`
  recommendation. Pure data, never authority: no threshold, no blocking, no
  merge/close — every input degrades OPEN, so absence is fail-safe, never a
  suppressed finding.
- compose-review wires it: `prevLedgerFacts` now recovers the previous
  work-list's Critical presence beside the round and volume; the assessment
  surfaces on three surfaces — a structured `convergence` field on the
  composed JSON, a rank-1 non-capping body disclosure, and a terminal
  CONVERGENCE line — each advisory-only and self-disclaiming, with a blank
  residual-risk inventory scaffold (attack surface · attacker-dependency ·
  blast radius) for the maintainer's risk-acceptance decision.

The exit the floor cannot provide: when the loop is provably stuck on
Criticals, the tool names the maintainer's decision (merge, carrying the
residual risk) instead of opening another round. Advisory only — it never
blocks this review.

Closes the convergence-exit gap in #9278; evidence and design in #9410.

* fix(review): surface the convergence advisory on every reachable event, gated on floor engagement (#9526)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(review): give the trimmed convergence advisory its own disclosure rank (#9526)

The advisory shared trim rank 1 with the deferral display, but every
rank-1 disclosure surface names "the deferred-findings list" — a fired
zero-deferral round whose body overflowed posted a trim notice asserting
a deferral list that never existed while the dropped advisory went
unnamed. The advisory now holds its own rank (and RANK_NAMES entry),
yielding after the deferral display and before the not-reviewed
disclosures. Adds the overflow fixture that pins the yield and the
relocated-arm firing fixture that pins the third thisCriticals term,
and corrects the prevLedgerFacts threat docstring: under `auto` the
floor-engagement conjunct is forgeable via the carried round, so the
only unforgeable conjunct is this round's own standing Critical.

* fix(review): count the script-lint gate's Criticals in the convergence signal (#9526)

The persistently-critical signal read `thisCriticals` before the gate
pushed its Criticals into `bodyCriticals`, and the ledger work-list
feeding the next round's persistence half omitted them too. A loop
whose standing blocker is the deterministic [lint] gate — the exact
shape the signal exists to name — held the whole conjunction
semantically while the advisory stayed silent: the count was taken
before the array was complete, and the gate-only round recorded no
sev 'C' for its successor to recover.

The assessment now runs after the relocated and gate pushes and reads
the completed array with the same semantics as the verdict's own `c`
(the explicit relocated term drops with the push that already carries
it), and the gate's Criticals join the marker work-list. Adds the
handler fixture arming the gate end to end — advisory fires, marker
records sev 'C' — and pins both branches of the trim notice's
copy-location conditional, which had no oracle on either side.

* fix(review): close the round-5 oracle gaps on the residual-risk advisory (#9526)

Round 5 reviewed the merge that landed #9461 underneath this branch and
found four suppress paths the merge introduced with no end-to-end oracle,
plus one standing comment overclaim. Each finding was reproduced as a
surviving mutant before the fix and re-run after, so every test added here
is one that actually kills something.

R5-2 — `residualRisk` is carried into the durable artifact instead of being
omitted from it. The omission's stated reason ("the advisory rides the
persisted body") is false on exactly the rounds that need the record: rank 2
sheds before the not-reviewed disclosures, so a fired-but-trimmed round left
a maintainer reading `.qwen/reviews` a "did not fit" breadcrumb and none of
the facts behind the `land-with-residual-risk` call. Its sibling
`convergence` is allow-listed one paragraph up for that precise reason, and
the merge had put the two on opposite rules. Shape-checked like every other
field on this boundary rather than passed through.

R5-1 — the persistence conjunct had no silence fixture. Every firing
fixture carries sev `C` in the prev ledger, so replacing the derivation with
a bare `true` shipped the suite green while a round introducing its FIRST
Critical would fire `land-with-residual-risk`. Added a fixture whose
predecessor holds Suggestions only, all other conjuncts true.

R5-3 — the enforcement-vs-reporting floor reading had no oracle for the one
input where the two disagree: a genuinely ABSENT `severityFloor` at round
>= 6, which the reporting reading folds to `auto`. Every advisory fixture
passed `severityFloor: 'auto'` explicitly, so the swap shipped green and
would publish "The severity floor will not converge it" over a round whose
enforcement backstop moved nothing. Added a fixture with no `severityFloor`
key at all.

R5-4 — the two silence fixtures asserted only absences. `prevLedgerFacts`
swallows every recovery failure into round 0, so a predecessor that never
loaded produced the same silence and the arms they claim to pin were
vacuous. Both now assert the VOLUME line quoting the predecessor's volume
as a positive recovery sentinel.

R4-1 — the marker path's second `scriptLintGate` run is left in place: it
lives in a different function from the body composer's, threading the value
across would add a seventh positional parameter for plumbing, and the two
agree because the gate is pure in `planPath` over inputs immutable within
one synchronous compose. What was wrong was the comment claiming it was
"the same gate the body ran"; it now states the actual invariant and the
actual hazard — an edit that filters what the BODY pushes must change this
list too.

packages/cli: typecheck, ESLint and Prettier clean; src/commands/review
4301 pass / 1 skipped. Mutation matrix (compose-review + save-artifact,
475 tests): baseline green; `prevHadCritical: true`, the reporting-reading
swap, dropping `residualRisk` from the persisted verdict, and a vacuous
ledger recovery each turn the suite red.

* fix(review): act on the round-6 deferred list for the residual-risk advisory (#9526)

Round 6 posted no findings and deferred ten observations under the
convergence posture. Eight are addressed here; each was reproduced as a
surviving mutant first and re-run after, and the two that are not addressed
are recorded below with the reason rather than left silent.

Correctness:

- The volume window straddled a posture change. The round the floor engages
  on compares a Critical-only volume against a predecessor that was still
  posting Suggestions — a drop that is the posture, not the loop — and on a
  flat pair the advisory could publish "the severity floor will not converge
  it" after one round of the floor. `ConvergenceFacts` now carries
  `prevFloor` and a recorded `o` predecessor suppresses. Read the way the
  sibling diagnosis in the same module reads it: a floor that was never
  recorded is not a floor that DIFFERS, so pre-field markers evaluate
  exactly as before. Pinned in both directions — deleting the guard and
  tightening it to reject unrecorded floors each turn the suite red.

- `noteTrimmedRanks`' tail clause keyed on the advisory instead of on the
  disclosures. Over a combined rank-2-and-3 drop it read "another copy — the
  advisory also rides the composed JSON", telling the operator the trimmed
  set was backed up when the half that is not backed up was exactly the half
  the sentence exists to rescue; over a rank-0 drop it read "their only
  other copy" for a paragraph the composed result does carry. It now keys on
  rank 3. The artifact stays unnamed here — naming it sent the operator to a
  deferral list that does not exist, which the existing test caught.

- The terminal `RESIDUAL-RISK:` record spread one labelled line over seven,
  six of them unlabelled, because the advisory carries a markdown table for
  the body. Collapsed at the print site only: the pipes survive, so the
  inventory's three columns still reach the operator on the round where the
  body budget shed the formatted copy.

Accuracy of the record:

- The `PersistedVerdict` comment claimed `residualRisk` sheds "before
  anything else". It is rank 2; `convergence` is rank 0. What they share is
  that both CAN go.
- The bundled skill enumerated two of the four trim ranks and stated the
  no-durable-copy rule without its exception. Both assertions in
  `SKILL.test.ts` move with the prose.

New oracles (test-only):

- The advisory-only guarantee — the claim the whole feature rests on — was
  unpinned: a fired round now asserts the event stays where the findings put
  it and that `cappedBy` gains nothing.
- The floor-futility sentence was pinned only negatively; it now has a
  positive assertion in both languages.
- The zh advisory's scaffold columns and its Critical-count interpolation
  slot had no oracle. `FIRE` is deliberately asymmetric (2 Criticals, volume
  3/3) so a template reading the wrong slot shows.
- The rank-ordering guard could not tell rank 2 from rank 3; the combined-
  drop test closes it — the `trim: 3 -> 2` mutant now fails five tests.

Not done, deliberately:

- The validator does not re-assert `criticals >= 1` / `posted >= prevPosted`.
  Those are `convergenceAssessment`'s construction invariants, and a second
  statement of them at the save boundary is a rule free to drift from the
  first — with the artifact, the durable record, as what gets thrown away
  when it does. Identity is pinned instead (`shape`, `recommendation`) and
  the counts are shape-checked.
- The marker path's second `scriptLintGate` run stands (R4-1); the reasoning
  is on that thread and at the call site.

packages/cli: 4305 pass / 1 skipped. packages/core skills: 376 pass.
Typecheck, ESLint and Prettier clean on both workspaces.

* fix(review): measure the residual-risk window on fresh findings, not totals (#9526)

Round 7 posted one Critical and it is correct. The volume conjunct compared
posting TOTALS, and Step 6 re-posts every still-standing ledger Critical
under its original id — so the total only ever rises and a converging loop
reads as a stuck one. Reproduced through the real `composeReview` before
touching anything: round 6 posts 5 first-time Criticals; the author fixes 3;
round 7 re-posts the 2 that stand and drafts 4 new. Fresh 5 -> 4 is a loop
settling, the total went 5 -> 6, and the advisory fired
`land-with-residual-risk` over it.

The window now runs on the fresh pair the marker already carries —
`postedFresh` and `prev.fresh`, the same numbers the loop-settling
observation in the same module trends on, so the two features cannot
disagree about what a round produced. `prev.fresh` absent degrades open.

Applying only that change would have introduced a second false fire, so it
does not ship alone. The posting total was silently covering a case the
fresh window is blind to: a reviewer finding nothing new for two rounds
while the author clears blockers sits at fresh 0 against fresh 0, which
"not falling" reads as stuck. Probed on the pre-change code — backlog 5 -> 3
with zero fresh both rounds is silent today (3 < 5) and would have fired
under a fresh-only window. The assessment therefore also takes the standing
Critical count and vetoes on observed shrinkage. A veto rather than a
requirement, on positive evidence only: the work-list it counts is the one
the marker's byte budget may have shortened, and an undercount can only hide
shrinkage, never manufacture it — so an unknown predecessor abstains instead
of silencing a genuinely stuck loop.

Unlike the sibling diagnosis, this signal does NOT require `prev.fresh > 0`.
That module is about a loop generating work; this one is about work that
never clears, and Criticals standing round after round with nothing new is
the shape itself, not a quiet loop. The backlog veto is what separates it
from a backlog being worked down.

The reported numbers are renamed with what they now measure — `posted` /
`prevPosted` become `fresh` / `prevFresh` on `ConvergenceFacts`,
`ConvergenceAssessment` and the persisted artifact — and the advisory prose
follows in both languages. Feeding fresh counts into fields printed as "the
posting volume" would have swapped one false record for another.

Verified as five shapes through the real command, then pinned as tests: the
reported fresh-shrinking loop is silent; the clearing backlog is silent; a
pre-fresh marker is silent; and both firing shapes still fire — the same
Criticals re-posted at zero fresh, and new Criticals every round.

Mutation matrix (539 tests): reverting the window to totals, deleting the
backlog veto, and tightening the veto to suppress on an unknown predecessor
each turn the suite red.

packages/cli: 4310 pass / 1 skipped. Typecheck, ESLint and Prettier clean.

* fix(review): prove the predecessor's floor enforced, don't trust its stamp (#9526)

R8-1 is correct. The posture-change guard paired two different readings
across the window's ends: this round's engagement is the strict
`criticalFloorInEffect`, but the predecessor's `floor` stamp is written from
`criticalFloorKind`, the reporting fold — which folds an absent
`severityFloor` into `auto` and stamps `c` on any round >= 6 the enforcement
backstop never touched. Reproduced through the real code first:

    criticalFloorKind(undefined, false, 6)     = 'auto-resolved'  -> stamps 'c'
    criticalFloorInEffect(undefined, false, 6) = false            -> Suggestions post

so a predecessor that still posted Suggestions passed the guard, and the
advisory published "the severity floor will not converge it" one round after
enforcement actually started.

Neither fix direction the finding names is taken. Restamping the marker from
the enforcement reading would leave the sibling diagnosis comparing this
round's reporting stamp against a predecessor's enforcement stamp — the same
cross-reading defect moved into #9623's feature — and #9623 chose the
reporting reading deliberately, because its advice quotes the floor back to
the author. Special-casing a "newly named" floor needs the predecessor's raw
`severityFloor`, which no marker carries.

The evidence is already in the work-list instead. Enforcement moves drafted
Suggestions out of the posting set before the marker is built, so an engaged
round's list is Critical-only and an un-enforced one is not — measured
through the real composer across all four postures:

    floor=critical (engaged)              work list ["C"]      stamp c
    floor=auto, round 7 (engaged)         work list ["C"]      stamp c
    floor ABSENT, round 7 (folded c)      work list ["C","S"]  stamp c   <- the hole
    floor=suggestion (not engaged)        work list ["C","S"]  stamp o

`prevPostedSuggestion` is that fact, and it suppresses on the POSITIVE
observation so the two ways it can be wrong land on opposite sides: a
shortened list that shed its Suggestion reads as engaged (the truncation
caveat the backlog veto already carries), while a pathless Suggestion an
engaged round left inline reads as un-enforced and costs one round of
silence. Unknown abstains, like every other fact read off that list.

Mutation matrix: deleting the guard, tightening it so an unknown predecessor
suppresses, and pointing the wiring at the wrong severity each turn the suite
red — the first on both the unit arm and the end-to-end fixture built from
the finding's own witness.

packages/cli: 4432 pass / 1 skipped. Typecheck, ESLint and Prettier clean.

* fix(review): refuse a pure-foreign work-list as this account's history (#9526)

Correct, and reproduced through the real composer before changing anything.
Recovery adopts the highest-round marker whoever posted it. Where that marker
was NOT merged over this account's own findings, this account's entries are
in no work list at all — the state `openCriticals` already refuses to infer
across, one screen up in the same function. Every prev-round fact this signal
reads comes off that list, and it read it unconditionally:

    pure-foreign  {foreign:true, merged:false}   -> FIRES
    own list      {foreign:false}                -> FIRES
    merged        {foreign:true, merged:true}    -> FIRES

An own round-6 marker that was a clean LGTM (empty findings, fresh 0, floor
stamped `c`), a foreign same-round marker carrying Criticals and no
Suggestions winning recovery, and one Critical drafted this round were enough
to publish "Criticals stood in the previous round's work-list and stand again
this round — land-with-residual-risk" over this account's own LGTM.

All three list-derived facts are withheld on that state, not just
`prevHadCritical`: it alone silences the assessment today, but leaving the
other two reading a stranger's list is a hole waiting for the next edit to
re-open. `prevPostedSuggestion` in particular reads ABSENCE, and a stranger's
Critical-only list is exactly the shape that reads as "the floor enforced".

Merged foreign lists are deliberately NOT withheld: the union keeps this
account's own certified entries under their own ids, which is the part that
makes the list speak for this account again — the same distinction
`openCriticals` draws.

The test drives all three arms and asserts them as one table, so the fix is
pinned in both directions: a mutant disabling the gate fires on the stranger,
and a mutant widening it to any `foreign` marker silences the merged arm.
Both turn the suite red, as does un-gating `prevHadCritical` alone.

Not changed, and recorded rather than left implicit: a TRUNCATED work-list
still reads as this account's. Truncation shortens our own list, which is a
different thing from a stranger's, and the direction it errs in is already
documented on `prevPostedSuggestion` and the backlog veto. Requiring
completeness would silence the advisory on precisely the deep-work-list
rounds it exists for.

packages/cli: 4433 pass / 1 skipped. Typecheck, ESLint and Prettier clean.

* fix(review): stop a gate Critical compounding, and qualify a truncated reading (#9526)

Round 11's two Criticals. Both reproduced through the real composer before
anything was changed.

R11-2 — a standing gate Critical entered the posting set twice, and the pair
compounded. This is a regression from this branch's own commit d72287cc: once
the gate's `[lint]` Criticals are in the carried work-list, SKILL Step 6's
still-standing rule tells the model to re-post the entry under its original
id while compose re-derives the same Critical from the report. `buildLedger`
keys by claimed id and the regenerated copy claims none, so it minted a
second id beside the carried one:

    ROUND1  work-list [R1-1]                  blocker rendered once
    ROUND2  work-list [R1-1, R2-1]            rendered twice
    ROUND3  work-list [R1-1, R2-1, R3-1]      rendered three times
    FLIP (revert the gate spread): round-1 work-list [], rendered once

`withoutGateReposts` drops the re-post, keeping the GATE's copy rather than
the model's. That direction is load-bearing: `[lint]` is not in
`DETERMINISTIC_TAG_RE` (`[build]`/`[test]`/`[probe]` only), so the model's
copy counts toward `criticalsNeedingVerify` — a linter-proven blocker was
pulling the unverified-blocker cap on every re-post round, and the probe
shows that cap disappearing with the fix. For the same reason the dedup runs
BEFORE `modelBodyCriticals` is captured: dropping the re-post from the body
alone left provenance still counting it, so the first draft of this fix fixed
the rendering and kept the cap.

Matched on the gate line's LOCATOR (the `` `path`:line CODE `` it opens
with, backticks normalised), not the whole string: a re-post is model prose
that carries the entry forward without reproducing the message byte for
byte, and an exact-match rule stopped deduping the moment the wording
drifted. The carried id is stripped through the ledger's own
`LEDGER_ID_READBACK`. The body composer's gate call is now the only one on
that path, so this also removes one half of R4-1's double invocation.

R11-1 — the residual-risk facts are read off a work-list that may be
known-truncated, without the completeness gate `openCriticals` applies. The
completeness gate is NOT restored, and that is the same call as round 8: a
whole-list requirement would silence the advisory on exactly the
deep-work-list rounds it exists for, which are the rounds the byte budget
shortens. What was wrong is what the code SAID about it. The block comment
claimed "every input degrades open to no assessment"; two of these inputs do
not. "No Suggestion, so the floor was enforcing" and "the backlog is not
shrinking" are read off ABSENCE, and a shortened list can only lose entries,
so both lean toward firing.

`prevTruncated` now rides the facts and the assessment — deciding nothing —
and the paragraph discloses, in both languages, that those two readings came
off an incomplete list. The sibling diagnosis in the same module qualifies
its own recurrence reading on the same fact; this follows that precedent
rather than inventing one. The block comment states the exception instead of
the blanket claim.

Mutation matrix: never rendering the caveat, wiring `prevTruncated` to a
constant, disabling the gate dedup, reverting the dedup to exact-match, and
removing it from the marker work-list each turn the suite red — alongside the
carried set (window on totals, pure-foreign gate, enforcement-evidence
guard).

packages/cli: 4697 pass / 1 skipped. Typecheck, ESLint and Prettier clean.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-23 12:40:24 +00:00
jinye
431a0bd9b0
fix(daemon): keep restored ask_user_question valid after load (#9763)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* fix(daemon): keep restored questions valid across load, send, and replay

Post-merge review of the restore path found illegal provider history, phantom rewind snapshots, dropped resume notices, and replay that finalized a question the load was about to re-hang.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): pin ask_user_question restore suppress wiring in acpAgent

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): skip persistence for a whole restored batch that ends unattended

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(core): pin restorable ask_user_question preservation on a real Config

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-23 05:03:31 +00:00
callmeYe
f1b1305a76
feat(models): support dual-role image generation models (#9650)
* feat(models): support dual-role image generation models

* fix(models): address dual-role image selector review

* test(cli): cover image model resolver rejection

* fix(models): preserve legacy vision image routes
2026-08-23 05:02:32 +00:00
Dragon
1e062a4d0f
perf(cli): raise VP scroll rendering to 60 FPS (#9681) 2026-08-23 04:50:28 +00:00
Shaojin Wen
509226260c
feat(review): back comment-status and presubmit for Aone Code targets (#9627)
* feat(review): back comment-status and presubmit for Aone Code targets

A second `--comment` round on an Aone MR re-posted every still-valid
finding as a new comment and never downgraded a self-MR review — both
flows were skipped for lack of a1 backing. Route Aone targets at the a1
reads (mr view / mr status / mr comment list / auth whoami) through the
same pure classification cores the GitHub path pins, so the report
schemas and the Step-7 downgrade semantics stay one contract:
parentNoteId threading, closed → resolved, outdated → stale (a
rewritten line stays re-postable), no commit anchors (code facts
degrade to unknown), and drift with no compare API fails safe. The
context-unavailable verdict cap stays until pr-context lands.

Closes #9613

* fix(review): harden Aone runners' pr_number guards and null gate payload

Address round-1 review findings on the Aone backing of comment-status
and presubmit:

- extractStatusChecks no longer throws a TypeError when a1 answers a
  bare null to `mr status`; the payload now reads as the designed
  unreadable gate state (undefined), capping the verdict like a
  still-running check instead of crashing presubmit with no report.
- comment-status and presubmit validate pr_number with fetch-pr's
  /^[1-9]\d*$/ grammar before Number() coercion, refusing '012'/'1e3'/
  '0x1f'/' 12'/'12.0' tokens that would query a different MR than the
  caller's label carries.
- Pin the two subject_type combinations no test covered (pathless
  comment WITH outdated:true; the live path+line shape) with
  mutation-probed assertions.
- Align the --host describes with the sibling commands' detection
  wording (omission no longer promises github.com), name the real
  bucket (`resolved`) in the review skill's Aone dedup note, and scope
  the design doc's remaining-unbacked claim to its own section.

* test(review): pin the Aone dedup seams the round-2 review named (#9627)

Four mutation-verified pins on the existing Aone backing, each closing
a round-2 Suggestion:

- classifyAoneChecks: the continue-scan cell of aoneCheckState — an
  unrecognized value in an earlier key beside a recognized verdict in a
  later key reads the verdict, not pending (a first-present-key mutant
  now fails)
- classifyAoneChecks: a context-keyed FAILED gate carries its name —
  the passing context-keyed case pinned nothing because passing gates
  never collect names
- both comment mappers: `note` beats `body` when BOTH keys are present
  (`??` does not coalesce `body: ''`, so an inverted priority would
  blank every recognition signal and re-post the whole review)
- aoneCommentToPresubmitComment: parentNoteId maps onto
  in_reply_to_id, including the absent-stays-unset half

No source changes; each pin fails under its named mutant and passes on
the current code.

* test(review): pin the five Aone seams the round-3 review named (#9627)

* fix(review): align Aone comment reads with measured a1 facts (#9627)

* fix(review): read fully-dropped Aone checks array as pending, not all-clear (#9627)

* fix(review): match SKILL.md self-PR wording to the revert-guard test

The merge resolution reworded the self-PR note to "matched against the
'a1 auth whoami' account", but SKILL.test.ts's revert guard (#9616, #9627)
pins the exact phrase "the MR author is matched against 'a1 auth whoami'".
Restore the pinned wording (semantics unchanged) so the bundled-skill test
passes.

* fix(ci): record qwen-autofix.yml's actual size in the workflow ratchet

The workflow-size ratchet failed on this PR: qwen-autofix.yml is 397656
bytes but .size-baseline recorded 392111 (5545 over, allowance 4096).

The oversize was inherited from main, not introduced here: main's ratchet
commit (a5d77eb8, #9677) shrank qwen-autofix.yml to 397656 but set the
baseline to 392111 — 5545 bytes below the file's actual size at that very
commit. This branch carries main's file unchanged (byte-identical), so its
CI is the first to trip the mismatch.

Growth is real in the sense that the file genuinely is 397656 bytes; per the
ratchet's own guidance ("if the growth is real, bump the number and say
why"), record the actual size so the ratchet measures future drift from
reality. The Post Coverage Comment failure is downstream of this (the Test
job exits before uploading the coverage artifact).

* fix(review): keep the pipeline's own pathless Aone summary out of the blocker index (#9627)

---------

Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-23 04:50:14 +00:00
Shaojin Wen
7f2c4416b3
docs(review): name the axis when two of them both call it "rank 3" (#9759)
The trim-rank move gave `trim` a rank 3, and the file already used a bare
"rank 3" for the `keep` default on the last-resort cut's axis. The two mean
opposite things — `trim: 3` is the LAST rank the ladder sheds, `keep: 3` is
the FIRST thing the cut spends — so a reader landing on the cannot-tell
block's "Deliberately untagged (rank 3, spent first by the last-resort cut)"
now reads it against a comment three hundred lines up saying rank 3 goes
last.

Every mention of the number on either axis now says which axis it is on:
the cannot-tell block states the collision outright, and the four trim-side
mentions and the one keep-side test comment are qualified. The `keep`
comment that already disambiguated itself ("No `trim` rank rides here") is
unchanged.

Comments only, no behaviour. Raised by the review as a deferred, non-blocking
item; taken now because it is the same class of drift the previous commit
closed, and shipping the ambiguity would have seeded the next one.
2026-08-23 02:04:18 +00:00
Shaojin Wen
72d3a845f7
fix(review): count a fix-induced re-report as first-time work (#9744)
* fix(review): count a fix-induced re-report as first-time work

Closes #9674.

A carried id has meant two different things since the fix-induced
disposition shipped, and the volume trend's first-time count read both as
re-posts. One is a re-post: a finding re-asserted under the id it already
had. The other is a new defect wearing the id of the entry whose fix
produced it, carried deliberately so the author reads one thread per
churning site instead of a new one every round. Counting that as a re-post
made the trend understate new work exactly where the loop was creating the
most of it — measured on the pull request that introduced the disposition, a
round that newly identified six defects and re-reported four of them under
earlier ids recorded a first-time count of two.

Neither count moves. They measure different things and both readings are
correct, which is why the two reconciliations the issue rules out stay ruled
out: excluding carried-id re-reports from the census would put the attributed
count outside it and every such census would be refused as impossible, and
counting them as first-time posts wholesale would tell the trend a
re-assertion is new work. What was missing is the distinction itself, so the
comment now carries it: a fix-induced re-report is marked, and the reader of
drafted comments passes that through to the count.

The marking sits after the id and its separator, never inside the id
grammar. That grammar is shared with the ledger's own carry, so widening it
to swallow a parenthetical would put a finding's identity on the same regex
as a model-written adjective — a spacing the wider grammar failed to
anticipate would stop matching the id and silently renumber the finding.
Read after the id, nothing about the token can cost it, and the reading is
correspondingly lenient about case and spacing because it governs only
whether a comment counts as first-time work. An unrecognised marking leaves
the draft counted as a re-post, which is what every round did before this
existed; a marking wrongly added to a still-stands is the expensive
direction, so the skill restricts the token rather than offering it as a way
to flag any carried finding as interesting.

The token is stripped from the claim before it reaches the work list. Left
in, it would ride into the next round as part of the text Step 6 re-locates
the claim by and the status table prints — machine vocabulary about how to
count one round, outliving the round it described. Beside no id it is
ordinary claim text and survives, because there is no entry there for it to
qualify and editing a finding's own words on the strength of a word it
opened with is not this token's business.

* fix(review): resolve orphaned readback doc and record the fresh-count seam (#9744)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(review): name the zero-prev masking round in the fresh-count seam note (#9744)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(review): stop the blocker's docblock forbidding what this PR ships

Three passages written when a carried id could only mean one thing now
forbid the rule this branch adds. The blocker's docblock rules out
"counting them as first-time POSTS" as one of two reconciliations that must
never be made; the skill's census paragraph says the volume trend is the
count "where a carried id is a re-post"; and a test comment restates the
same premise. Each was true before a fix-induced re-report could be marked,
and each now tells the next reader to undo the code beside it.

The distinction the passages were protecting is real and stays. What they
ruled out was reading a carried id as first-time work BY INFERENCE, which
would count every re-assertion of a standing finding as new work — still
wrong, and still what `isFreshDraft` refuses. What this branch added is
narrower and is not an inference: the round marks the re-report, and only a
marked one counts. An unmarked carried id is a re-post to the trend exactly
as before, so the two counts still diverge by design; what is gone is the
premise that a carried id can mean only one thing.

The census paragraph gains the consequence that follows for whoever writes
the comment: a fix-induced finding counted in the census but left unmarked
in the body is counted by neither number.

Prose only — no logic, no test assertion changes. Reported twice by the
review as a deferred finding and left standing under the code-age rule,
which is correct as a posting decision and not a reason to leave a
contradiction in the file.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-23 01:57:19 +00:00
Shaojin Wen
ec8a8a1a97
feat(review): back pr-context on Aone Code targets (#9621)
* feat(review): back pr-context on Aone Code targets

pr-context was the one read subcommand still gh-direct, so every Aone
run was forced context-unavailable: the verdict capped at COMMENT (the
wired a1 approval could never fire), Agent 0 skipped, and the machine
ledger never recovered from posted summaries. Route it through the
platform reader with a normalized context bundle; Aone serves it from
mr view + the flat comment list (thread comments carry the ledger),
GitHub's implementation is an extraction of the existing calls — its
output stays byte-identical. The forced cap leaves the Aone write path
for parity with GitHub's state-claim handling, and the refetch commands
a context file emits bake --pr on Aone, where comment bodies are
addressed per-MR.

* fix(review): keep Aone ledger carriers out of the blocker re-check (#9621)

On Aone this pipeline's own round summaries are path-less comments, so
they ride pr-context's issue channel, where their visible
**[Critical]** lines self-promoted every prior Critical-bearing summary
into "Blockers to re-check" — rendering each prior Critical three
times (beside the ledger section and the inline roots that own the
same findings) and spending the section budget on the pipeline's own
prose until genuine human blockers degraded to snippets. Exclude
bodies carrying the ledger marker from issue-channel promotion and the
stdout count, strip the marker out of the settled snippet, and switch
the pr_number guard to the canonical isPositivePrNumber so 0x10/5.
spellings cannot fragment side-file continuity. Pin the witnesses the
round's findings name: the guard, args.host forwarding, the
issue-kind --pr refetch branch, the account-first author keying, and
the GitHub test suites' independence from the cwd-origin probe.

* fix(review): refuse pr_number spellings that do not round-trip (#9621)

isPositivePrNumber alone admits two spellings whose Number() value does
not round-trip to the raw string: leading zeros (007 fetches 7 but the
raw string labels the heading and the prev-ledger side file, so a later
7 run reads a different side file and the round counter restarts) and
digit strings above Number.MAX_SAFE_INTEGER (Number() silently rounds
them, fetching a different PR than the labels announce). Add the
safe-integer and no-leading-zero conjuncts — matching fetch-pr's
[1-9]\d* rule — so every admitted input satisfies String(Number(x)) === x.

Also pin the witnesses the round-2 review names: the commit_id
round-trip through the GitHub reader and toRawReview into the persisted
side file (both spreads were unwitnessed), the stale force-applies
comment in submit-aone.test.ts the cap removal outdates, and the setup
batch's Aone carve-out for the unbacked comment-status call.

* docs(review): align Aone docs with the landed no-ancestry anchor rule and comment-status skips

D6 described the AGit-Flow anchor as inert until the incremental rule
landed, but that rule (#9630) merged while this branch was in flight —
anchors now delta-scope Aone re-reviews. SKILL.md's comment-status
section and Step 6's report-existence guard now name the Aone skip the
setup batch already carries, so no path sends an Aone run at the
unbacked command or at a report that was never written.

* docs(review): annotate #9616 as landed and define the report-less re-check rule

The out-of-scope list still read self-PR detection as open work although
#9629 shipped it into this branch's merge base — annotate it like the
sibling #9618 entry. Step 6's report-existence guard pointed report-less
runs at a re-derivation the skill never defines; replace it with the
explicit rule: no per-thread status routing, no hand-derived substitute,
rule from the code at the reviewed commit, cannot-tell over a guess.

* fix(review): route the context head through aoneHeadSha and close the round-5 findings

getReviewContext read sourceBranch raw while every other head read trims
— a padded server value diverged the context file from the rest of the
run (phantom-drift shape). getCurrentUser now honors the seam contract
on the anomalous whoami shapes instead of leaking untagged throws and
non-string accounts. Step 6's report-less rule no longer contradicts
the comment-status failure contract: runs where the command ran and
failed keep the "re-derive if needed" fallback. The Aone paragraph
names comment-body among the backed reads, and witness tests pin the
identity gate's carriers key and the head normalization.

* fix(review): shape-check the Aone comment listing in getReviewContext

a1 can answer repo mr comment list with an exit-0 a1.error/v1 error
object (backend auth failure or client timeout — measured by cleanup's
a1CommentList on the identical payload). Without a guard the object
survives the ?? [] coalesce and .filter throws an untagged TypeError,
losing the envelope's actionable message at exactly the recoverable
moment. Guard as the provider family already does and surface the
cause; witness tests pin both envelope shapes (mutant-checked).

* test(review): pin getCommentBody's body-field fallback (mutant-checked)

* fix(review): union resolved comments into the Aone context bundle

The default comment list excludes resolved comments (measured by the
cleanup audit) while GitHub's REST fetches include them, so a resolved
blocker/marker root never reached the re-check walk or the fail-closed
identity gate. Union the default and --resolved listings as the audit
does, dedupe by id, fail closed on either listing's error envelope, and
disclose the residual that resolved replies stay invisible; witness
tests mutant-checked.

* fix(review): serve resolved comments and guard the envelope in getCommentBody

getCommentBody queried only the default comment list while the context
bundle it serves refetches for unions in resolved comments — a resolved
id named by a truncation note threw "not found" every time, and an
exit-0 a1.error/v1 envelope threw an untagged TypeError that lost the
actionable message. Extract the shape-checked default+resolved union
helper and read both sites through it; witness tests mutant-checked.

* ci: correct qwen-autofix.yml size baseline to its actual post-migration size

#9677 shrank qwen-autofix.yml from 431526 to 397656 bytes (prose moved to
the design record) but recorded the baseline at 392111, 5545 below the
file's own post-change size, so the first PR to run the ratchet tripped it.
This branch introduces zero growth to the file (byte-identical to main);
the bump aligns the baseline with reality. No workflow content changes.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-23 01:10:32 +00:00
qqqys
98fa2e9770
feat(cli): enable dynamic workflows from a settings key (#9098)
* feat(cli): enable dynamic workflows from a settings key

`ConfigParameters.workflowsEnabled` is declared, defaulted, and read by
`Config.isWorkflowsEnabled()` — but `loadCliConfig` never writes it, so no
setting has ever reached it. The only way to turn dynamic workflows on is
the undocumented `QWEN_CODE_ENABLE_WORKFLOWS=1`, which has to be exported
in every shell that launches qwen. AGENTS.md names this shape directly: an
optional field that is declared and read but never set by any caller is a
dead switch.

Add `tools.workflowsEnabled` to the settings schema and populate the field
from it. Precedence is unchanged and still resolved in core:
`QWEN_CODE_DISABLE_WORKFLOWS` beats everything, then
`QWEN_CODE_ENABLE_WORKFLOWS`, then the setting. Because `settings.merged`
already folds the System scope, an operator gets a fleet-wide force-off
with no extra code.

`requiresRestart` is load-bearing rather than decorative: the Workflow tool
is registered once while the tool registry is built, `/workflows` is gated
when commands load, and keyword steering resolves at startup — so a
mid-session toggle would leave the dialog claiming the feature is on while
the tool is absent from the registry.

The setting description also disambiguates it from the unrelated
`experimental.sessionWorkflow` plan-and-review view, which shares the word
"workflow" and would otherwise be easy to confuse in the settings dialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(cli): clarify workflow feature controls

* test(cli): cover workflow command gating

* fix(cli): restrict workflow opt-in scope

* fix(cli): keep workflow opt-in user-owned

* test(cli): cover workflow system scopes

* refactor(cli): drive workspace-restricted settings from one list

R4-3: the restricted set was hand-maintained in three parallel places — a
per-key warning block, the condition in `stripWorkspaceRestrictedSettings`,
and that function's destructure. Adding one restricted setting needed three
synchronized edits, and either omission is silent: forgetting the warning
discards a workspace value with no diagnostic, forgetting the strip honors a
value the warning says is ignored.

`WORKSPACE_RESTRICTED_SETTINGS` is now the single source, and the warning
loop and the strip both derive from it. It lives in `settingsUtils.ts`
rather than `settings.ts` because `settings.ts` already value-imports that
module — defining it there and importing it back would close a runtime
import cycle.

R4-2: `tools.workflowsEnabled` is the first setting that is both
`showInDialog: true` and stripped from Workspace scope, so the dialog
offered a toggle that silently never took effect — it renders from the raw
scope file, so it kept showing the value it wrote while the feature stayed
at its merged value, leaving a dead entry in the repo's .qwen/settings.json.
`getDialogSettingKeys` gained `excludeWorkspaceRestricted`, which the dialog
passes when the selected scope is Workspace. The scope comparison stays in
the component so settingsUtils keeps its type-only dependency on settings.ts.
Unlike `showInDialog: false` (what the two pre-existing restricted settings
use), the setting stays visible and editable under the scopes that honor it.

Verified: settings 169/169, settingsUtils 85/85, BuiltinCommandLoader 13/13;
packages/cli typecheck clean. Mutation-checked both ways — forcing the
filter off fails 1 test, dropping a key from the list fails 3.
SettingsDialog.test.tsx's 23 failures are pre-existing and environmental:
identical counts on upstream/main and on this branch before the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): reject workspace-restricted settings at the daemon API too

R8-1. The workspace restriction stopped at the TUI dialog. `stripWorkspace
RestrictedSettings` drops these keys before every merge, so a workspace-scope
write through the settings API persists a committable dead entry into the
repo's `.qwen/settings.json` and answers 200 + `requiresRestart: true` while
the feature never turns on — GET then reports `workspace: true` beside
`effective: false`, and the warnings channel carries only `corrupted`, so the
client never learns the write was inert. Exactly the trap the SettingsDialog
comment in this same PR says it eliminates, one layer over.

`tools.workflowsEnabled` is the first workspace-restricted key with
`showInDialog: true`, which is what puts it in `getDialogSettingKeys()` and
therefore in `getAllowedKeys()` — the two pre-existing restricted keys are
`showInDialog: false` and never reached the API.

Both POST handlers now call one shared `rejectWorkspaceRestrictedWrite`,
answering 400 `workspace_restricted_setting`. One helper rather than two
copies, for the reason the previous commit collapsed the warning/strip pair.
User scope is untouched — that scope honors the key, and a guard that reached
it would kill this PR's whole enablement path.

Verified: workspace-settings 22/22, settings 169/169, settingsUtils 85/85.
Mutation-checked three ways — dropping either call site fails a test, and
widening the guard past workspace scope fails the user-scope test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
2026-08-23 00:33:51 +00:00
qqqys
bd247128fd
feat(goal): account the tokens a Goal spends (#9583)
* feat(goal): account the tokens a Goal spends

A Goal reported how many turns it had run and how long it had been active, but
never what it cost. That is the number a user needs to judge whether a long
autonomous run is worth continuing, and the one every future limit has to be
expressed in — a budget cannot be enforced against a figure nobody keeps.

`GoalRecord` now carries `tokensUsed`, summed across the Goal's turns by
`reduceGoalTurnFinished`, and `get_goal` reports it in the unpermitted
`lastGoal` summary beside the turn count.

The figure comes from the chat recorder, which already receives every assistant
turn's usage stamped with the Goal permit that produced it. Attribution is
therefore settled where the spend is recorded rather than reconstructed
afterwards from session totals: a user turn interleaved with an autonomous run
belongs to no Goal turn, and a resumed session's replayed history is not a Goal
turn's spend either. The runtime asks the ledger for one turn by id when that
turn finishes, which is also why the accounting needs no coordination with
session swaps.

A runtime with no ledger, a ledger that throws, and a turn that made no model
calls all bill zero rather than guessing, and none of them fails the turn.
Goals recovered from a transcript written before the field existed restore with
zero spend. No limit is introduced here — this only counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(goal): cover recorder token accounting

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
2026-08-22 18:48:15 +00:00
Shaojin Wen
f829a02896
feat(review): validate Aone inline anchors against the captured diff before posting (#9634)
* feat(review): validate Aone inline anchors against the captured diff before posting

Aone Code performs no server-side anchor validation — a controlled probe
(scratch MR 29427547, a1 v0.2.51) proved any --line integer posts, and
an old-side number silently lands on the same-numbered new-side line.
The old side cannot be anchored at all, and file-level comments drop
their path.

Pin the removed-line semantics for the Aone write path: submit's Aone
branch now validates every well-formed inline anchor against the
review's captured diff before posting. An unanchorable Critical is
relocated into the summary body, an unanchorable Suggestion discarded
and counted — the GitHub 422-recovery dispose, performed in code — each
disclosed in the terminal. A missing captured diff refuses the whole
post; malformed shapes (missing path/line, reversed range,
renders-as-nothing) keep their consistency-gate refusals, and a garbage
state.bodyCriticals stands the gate down so compose's pinned refusal
fires. The GitHub path is untouched — its server performs this
validation.

Issue #9615

* fix(review): reject unpostable anchors and unify the Aone gate's shape refusals

- validateNewSideAnchors now rejects the input domain (fractional/zero/negative
  lines and reversed ranges) before the hunk scan, so its verdict can no longer
  certify an anchor the zero-validation Aone platform would post silently wrong.
- Extract the consistency gate's per-comment shape checks into one shared
  predicate (commentShapeProblems) read by both the loud refusal and the Aone
  anchor gate, so a shape the gate disposes is never a refusal the operator
  misses (open fence, start_line-without-side). The path check becomes a type
  check, closing truthy non-string paths that reached the write seam unvouched.

* fix(review): generalise the Aone gate's stand-down and harden its relocated entries

Round-2 review fixes for the Aone anchor gate:

- The stand-down now keys on ANY degrade that touches the payload and
  covers every compose-owned garbage shape: bodyCriticals that is not an
  array of strings, or a suggestionsDiscarded compose's counter refuses.
  The countability test reads compose's OWN acceptance table (toCount,
  exported as the total tryToCount), so the gate's merge and compose's
  counter can never drift — an integer-but-not-safe count now merges
  instead of silently dropping the gate's discards.
- The relocated entry's claim extraction strips a leading marker RUN
  (fixpoint, like every other strip) and treats a fence-delimiter claim
  line as absent — both shapes used to leak raw markers or junk
  delimiters into the posted summary-body blocker line.
- The gate keeps the model-authored comment indices through its removal
  (and floor enforcement keeps them through its own), so the consistency
  gate's refusal names the culprit in the model's payload JSON instead of
  a renumbered position the re-compose loop cannot act on.
- A --dry-run with a missing capture no longer exits 3: it writes
  nothing, so it skips the gate with a disclosure and reports
  wouldPost: false (reason: aone-diff-missing); the exit-3 refusal stays
  reserved for the real write.
- The MULTI_DIFF fixture's second hunk header becomes byte-exact git
  output (@@ -20,0 +22,2 @@, probed against git itself).
- The design doc gains the gate-relocation doctrine (relocated entries
  deliberately inherit the model's own tag-exemption treatment), the
  dry-run carve-out in the failure-shape table, and the corrected
  fence/one-line-channel claim.

* fix(review): close the anchor-gate witness gaps and a footer-leak in the relocated entry

Gap-fill on top of the round-2 gate hardening:

- The relocated entry's claim extraction strips the canonical footer
  FIRST: with an empty claim line (a marker-plus-separator-only body),
  the separator strip eats the newline+colon and the extraction falls
  THROUGH into the appended footer's first line, posting it as the
  claim. Witness added for the placeholder shape.
- Pin the multi-line relocation entry CONTENT (it must cite the claimed
  end line, not the start — the start sits inside the hunk and looks
  fine) and the disclosure naming it.
- Witnesses for the remaining mutant-tested gaps: a range whose start
  sits outside every hunk and end inside (the startLine mapping), the
  dry-run compose parity (preview composes from the gate-corrected
  payload), the suggestionsDiscarded 0 merge boundary, the empty-path
  shape (loud refusal, never a gate disposal), a declared LEFT
  start_side without a start_line, and the equal-boundary range
  (start_line === line, a shape GitHub itself produces).
- The routing suites run from a per-test fixture cwd, so the
  captured-diff seeding and its cleanup can no longer overwrite or
  delete a same-numbered live capture in the real vitest cwd.

Issue #9615

* fix(review): sanitise relocated-entry paths and stand down over any compose-refused bodyCriticals

* fix(review): keep the anchor gate's captured diff when resetting the receipt state

The Aone receipt suite's beforeEach wiped the whole .qwen tree to start
from no receipt — deleting the captured diff the anchor gate needs along
with it. Every post then died at the gate's missing-capture refusal and
no receipt was ever written (ENOENT in the four receipt tests on CI).
Remove only the receipt file; the seeded diff survives.

* fix(review): close the anchor-gate entrances the review rounds demonstrated

Round-3 remediation of the review comments on the Aone anchor gate:

- R3-2 (structural): the BUILT relocated entry is now validated against
  compose's own ingestion (tryIngestBodyCriticals over the single entry)
  before the relocate is disclosed, and any refusal degrades the entry to
  the inert constant `finding — (no path):<line>` — the entrance space is
  unbounded model text and compose's acceptance is the authority, so a
  shape the enumerated guards never anticipated degrades the entry
  instead of refusing the whole post mid-degrade. The demonstrated
  entrance (a lone CR inside the claim: it passes the leading-fence
  guard, compose's CR normalisation then splits the entry and the second
  line leads with a fence delimiter) is covered by a witness.
- Ledger collision: the relocated entry flips to `<claim> — <path>:<line>`
  — the claim leads, so a carried id keeps position 0 and the ^-anchored
  ledger readback matches instead of silently renumbering a carried
  finding as new. Witness asserts the id survives the readback regex.
- R7-1: an explicit JSON null side/startSide reads as ABSENT (defaults
  to RIGHT), the model's idiom for an omitted optional field — never a
  declared old side. Unit and gate-level witnesses.
- R3-3: witness for the non-identity authoredIndices branch — the gate
  renumbers the array, floor enforcement keys on the post-gate array,
  and the remap drops the comment floor enforcement names.
- R4-2: the hostile-paths test gains the \r-bearing path (compose's
  ingestion normalises a bare CR to a line break — the same hostile
  shape as \n; the guard's \r half was unwitnessed).
- R3-5: the design doc states the carve-out — the non-RIGHT degrade runs
  for single-line comments only; a multi-line non-RIGHT comment keeps
  the consistency gate's whole-post refusal; null side is absent, not a
  declaration. The failure-shapes table splits the row accordingly.

Issue #9615

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-22 17:45:27 +00:00
jinye
2172721405
feat(cli): restore each daemon session onto its last selected model (#9687)
* feat(cli): restore each daemon session onto its last selected model

Idle detach currently rebuilds Config from settings.model.name, so session A picks up whatever model session B last switched to.

Fixes #9686

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address session-model persistence review findings

- reader: always select the last assistant record into the restore read
  set so the legacy lastAssistantModel fallback still fires when a
  trailing chat_compression candidate excludes it from the resume read
- recorder: assign currentSessionModel before the awaited write so a
  rewind landing in the pending-write window re-anchors the new binding
  instead of the stale one
- reader/recorder: reject non-string session_model payload fields
  instead of crashing the restore path on malformed transcripts
- protocol doc: describe the session_model append as best-effort, not
  an unconditional consequence of a successful switch
- cli: import RUNTIME_SNAPSHOT_PREFIX/stripRuntimeSnapshotPrefix from
  core instead of duplicating the prefix algorithm locally
- tests: pin the isRuntime/baseUrl payload dimension, the prefix and
  route-mismatch false arms, the neither-field fallback, and regression
  coverage for the two fixes above

* fix(cli): keep daemon session-model restore from failing load

Pre-auth restore skipped the last-assistant fallback, and a recorded qwen-oauth binding could hard-fail load when cached credentials were gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): roll session-model auth retry back onto the settings route

Same-id baseUrl restores and runtime-only settings models were skipping or breaking the fallback, which made load fail on the recorded credential set.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): keep empty daemon sessions from creating a transcript

Recording the session model on newSession wrote a jsonl file before any user content, so close/delete/child-death left the id occupied and listing still showed the empty session.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): allowlist restored session-model routes against the registry

JSONL baseUrl is only a registry selector, so unknown hosts are dropped before switchModel. Restore also keeps the last valid session_model payload instead of falling through a torn trailing record.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): retry session-model auth after same-id snapshot restore

The retry gate ignored runtime-snapshot identity, so restoring an implicit
registry route off a same-id snapshot looked unchanged and skipped rollback.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 16:33:36 +00:00
Shaojin Wen
6c742ec792
feat(review): ask each fix for its test, and rule on non-convergence (#9596)
* feat(review): ask each fix for its test, and rule on non-convergence

The review-fix-re-review loop is its own largest customer. Provenance
analysis of six multi-round takeover pull requests attributed each
post-first-round finding to the commit that introduced the line it anchors
on: roughly a third were introduced by the fix round immediately before the
review that found them, overwhelmingly as a guard or branch with no test of
its own. That measurement produced a fix on the loop side, where it reaches
exactly one fixer. Most pull requests are not fixed by a bot the review can
configure, and whoever does fix a finding reads only the comment.

So the acceptance criterion moves into the finding and into the posted
comment. A finding whose suggested fix adds a guard, a branch, or a behavior
now names the test that must go red if the fix is removed, and the comment
asks for the mutation that proves it. The criterion never gates reporting: a
finding whose fix cannot be pinned is filed anyway, because a bar on
reporting would trade rounds for missed defects, and the evidence rule that
governs what confirms a finding is a separate one.

Second, a round stops renumbering its own churn. A new defect the reviewer
can trace to the change that answered a previous entry is re-reported under
that entry's id rather than taking a fresh one, so the author reads one
thread per site instead of a new one every round, and the cross-round work
list stops spending an id per round on a site the loop is circling.
Attribution is bookkeeping and never a posting decision; it applies only
when the new defect is at least as severe and as confident as the entry it
carries; and anything it cannot trace takes a fresh id, which is what every
round did before.

Third, that attribution produces a count, and the count is what ends a loop
the review cannot close by filing more findings. Each round hands over how
many findings first appeared and how many of those it attributed to the
previous round's fixes. The command owns the threshold, carries the streak
across rounds, and files its own blocking finding on the second consecutive
round in which most of the round's new work was work the previous round
created. It reads the attributed count deliberately, not the count of
findings on newly pushed lines: a pull request whose author pushed a feature
between rounds created none of them out of the review, and a bar built on
the looser number would block a pull request for growing.

* fix(review): cross-check the churn census and harden the streak's edges

Round-1 review findings on the non-convergence mechanism:

- Refuse a census whose fresh count exceeds everything the round
  reports (drafted comments, body Criticals, deferrals). The census is
  the model-written half of the trigger; this one-sided bound is the
  cross-check it gets before it can arm the streak, so a round that
  reported nothing can no longer file the blocker on the model's
  say-so alone.
- persistRecoveredLedger's anonymous-advance branch now drops
  churnRounds/fresh/induced with the other round-specific facts: a
  streak re-dated across a round this account never ran would arm the
  blocker one round early and discard the foreign winner's own streak
  state. The plain recovery path round-trips them, and both seams are
  now pinned.
- SKILL Step 6's fix-induced rule caps re-reports at one per original
  id per round — two same-id entries are a duplicate id, and the
  artifact validator refuses the round's findings whole.
- Reword the posted blocker and its docblocks to what the arithmetic
  actually does: the bar is half-or-more (not "most"), it keys on the
  attributed count (not findings on new lines), and the streak counts
  rounds against the bar — rounds that could not measure carry the
  count — rather than calendar-consecutive rounds.
- parseLedger clamps a recovered streak to the marker's own round:
  the streak counts rounds inside the round it rides, and an
  unclamped forged streak inflates the posted ordinal past everything
  the pull request ever ran.
- Witness pins for the gaps the reviewers probed: the >= filing
  condition at streak 3, ordinalSuffix past 2 (rd/teen-th/st),
  CHURN_MIN_FRESH from both sides, and the full corrected blocker
  text.

* fix(review): strip foreign churn state at the recovery seam

Round-2 review findings on the non-convergence mechanism:

- recoverLedger now strips churnRounds/fresh/induced from a foreign
  winner beside the anchor. Left riding, any account that can submit a
  review could plant a streak through the identity-known write path and
  trip the blocker one honest census later on a pull request that never
  churned; the anonymous-advance drop stays as defence in depth.
- A below-minimum census carries the streak like an absent one — three
  findings are rounding, not a trend — so a pull request alternating
  above-bar rounds with small ones still reaches the filing bar; the
  filing condition takes back its explicit above-bar guard, which the
  carry makes reachable again.
- Round 1 refuses a census outright: with no predecessor nothing can be
  fix-induced, symmetric with the round-0 streak guard.
- SKILL Step 6 counts fresh over the three reporting channels the module
  cross-checks — deferrals in, terminal-only and unanchorable drafts
  out — and Step 4's aggregate template gains the Fix witness slot
  Step 6 already names.
- Witness pins for what the reviewers mutated: the seam strip and its
  round trip, the three-channel sum on both non-drafted channels, the
  carry and its guard, the round-1 refusal, the finder brief's
  fix-witness format, and the aggregate slot.

* fix(review): restore own churn state at the union, clamp the side-file streak

* fix(review): name the churn group once, and part the two fresh counts

The convergence state was hand-enumerated at three production seams while
the volume group beside it documents a single shared list as the remedy for
a bug that already shipped once there — a field kept at one seam and shed at
the other. Nothing reds on a missed enumeration until a fourth field is
added, and the two ways to miss it are both silent: left in the side file
after the counter advances past the round it describes, or dropped from the
restore that protects this account's own data on a merged round. The group
now has one name, and the strip, the restore and the counter-advance branch
all read it.

The other half is a collision of words, not of arithmetic. One posted body
carried two counts of what a round did for the first time: this mechanism
counts DEFECTS newly identified, and the volume trend counts inline comments
POSTED for the first time. They legitimately differ — a fix-induced defect
re-reported under the id it came from is new work here and a re-post there —
so a round can newly identify six defects while posting two first-time
comments, and both numbers are right. Written as "findings first filed"
beside "reported for the first time", neither could be trusted. The blocker
now says "defects newly identified", and both the module and the skill
record why the two must not be reconciled by changing either one: excluding
carried-id re-reports from the census would put the attributed count outside
it and every such census would be refused as impossible, while counting them
as first-time posts would tell the trend that a re-post is new work.

Four witness pins close the gaps the last rounds left. The census
cross-check summed three reporting channels but every arm populated one at a
time, so a non-additive reduction shipped green and would refuse a census on
the ordinary shape of a round with body blockers beside inline findings. The
same-round union's churn restore had no pin through the persist seam — the
existing foreign-winner arm is cross-round, where the state is already gone
and a second drop is a no-op. The anonymous recovery walk's churn strip was
unpinned, so a refactor gating it on a known identity would let a foreign
streak ride an identity blip into the side file. And the finding format's
N/A exemption was pinned only up to its prefix, so deleting the clause that
keeps the criterion from becoming a bar shipped green in the copy that
actually reaches the agents.

* fix(review): name fix-induced in the ruling, pin the census clauses, drop the unread marker pair

* fix(review): carry the churn streak through unmeasured rounds

The carry contract says unmeasured rounds carry the count, and two seams
broke it for the cumulative streak while handling it correctly for the
per-round volume:

- The union restored own churn state only when the own marker described
  the SAME round as the winner, so a strictly NEWER foreign winner
  silently zeroed this account's standing streak — on a PR two accounts
  alternate on, neither ever reached the filing bar. The streak now
  restores across the round gap; only pickVolume stays same-round gated.
  No foreign state enters: the winner's streak is stripped at the
  recovery seam, and the restore spreads only the own marker's state.

- The anonymous-advance branch dropped the streak from this machine's
  own side file, so an identity blip (the gh api user failure the branch
  already anticipates for the volume) reset a standing claim; repeated
  blips kept the blocker unreachable on exactly the churning PRs. The
  drop rationale clauses do not apply — the winner's streak cannot reach
  this seam, and carrying arms nothing early because filing still needs
  THIS round's own above-bar census. The streak now carries, matching
  the sibling recovery-threw state and the filed blocker's own body.

Also part duplicate-dropped findings from the census `fresh`: they
restate defects an earlier round identified (the duplicates paragraph
discloses the confirmation; it is not a fourth reporting channel), and
counting them let the module refuse a census the rule as written
licensed. The exclusion is now explicit in SKILL's NOT-counted list and
pinned by the census contract test.

Witnesses: the cross-round persist test now asserts own streak restored
(1) and planted streak gone (never 4); the anonymous-advance test
asserts the streak survives the counter advance. Mutation probes on
each guard red when removed, green when restored.

* fix(review): part "reset" from "not recorded" at the churn seams

Two blockers landed on the same seam pointing opposite ways: one that the
identity-known write drops a standing streak, one that the anonymous-advance
branch keeps a stale one. Applying both suggestions as written would have
reverted a seam that had already been reversed once, so neither is applied
directly; the shared cause is fixed instead.

That cause is an ambiguity. A round measuring below the bar resets by
stamping no streak at all, so "no churn state" is written by a reset and by a
marker that was never read, and the two paths resolved it in opposite
directions. Recovery now reports whether an own marker was actually READ —
distinct from whether an own review exists, which is the case the corrupted
marker falls into — and the seams read that instead of guessing from absence.

The identity-known write carries the file's streak only when no own marker
was read: nothing authoritative said reset, so the file still holds the last
state this account certified. When one was read, it has spoken in whichever
direction and the write leaves it alone, so a real reset still lands.

The anonymous-advance branch sheds the streak with the volume. The argument
for keeping it was that a carried streak arms nothing early because filing
still needs the round's own above-bar census; that shows it is only USED
where a measured round finds it, not that it is still true there. With no
identity this branch cannot tell this account's own reset marker from a
stranger's, and carrying one lets a later census reach the bar a round early
with the blocker's own body claiming rounds that did not pass. Dropping costs
only the outage: the own marker stays on the pull request, so the next
identity-known recovery re-establishes the true streak.

The rule the two now share is one sentence. Carry while the state is known to
be ours and current; drop where it can be neither attributed nor dated.

Three smaller things fell out of checking the fix rather than the findings. A
carried streak is read through the ledger's own reader and clamped to the
round it is written beside, because this is the first path where bytes from
the file survive a write instead of being replaced by it. The anonymous whole
write now sheds the churn group as it already shed the volume, so that seam
defends itself instead of resting on an upstream strip, and the assertion
covering it was rewritten over a fixture that actually carries a streak — it
had been holding vacuously. And two guards that no mutation could redden were
resolved explicitly: one removed as an invariant of the strip above it, one
kept as defence in depth with its unreachability and its behavioural pins
named, because it sits on the exact axis the second blocker was about.
2026-08-22 16:24:04 +00:00
Shaojin Wen
079e22a914
feat(review): add temporal-reachability and incident-replay lenses (#9708)
* feat(review): add temporal-reachability and incident-replay lenses

Two blind-spot fixes measured on PR #9655's escaped P1 (a post-run
--capture that could not steer the run it documents, plus brief text
telling the witness to quote it as though it had):

- Agent 1c: reachability gains a TIME axis. A value produced after
  every decision it should influence is a record, not a mechanism;
  when documentation or workflow guidance treats the record as a
  mechanism, that is the Critical, with 'produced at X, needed at Y,
  Y precedes X' as the whole trace.
- Agent 0: a motivating incident narrated in the PR context is
  replayed step by step against the post-change workflow, regardless
  of closing-keyword formality. An unchanged outcome is a Critical
  even when the diff faithfully implements what its issue prescribed —
  an issue can prescribe a remedy that never reaches its own observed
  failure. An empty closing set no longer empties the replay duty.

* fix(review): pin the new lenses and give the replay an enforceable contract

Round-1 review feedback on this PR, all five findings addressed:

- R1-1/2/3: the three added passages were unpinned — a future deletion
  shipped green. Weld-style pins added in agent-prompt.test.ts (the
  enumeration-trap precedent), covering the replay duty, its un-gating,
  the TIME-axis paragraph, the trace format, and the verifier clause.
- R1-4: the empty-scope return now carries a fourth evidence item — the
  replay's outcome (the step that changes, or the reason none does), or
  an explicit statement that the description narrates no incident — so a
  skipped replay never reads identically to a performed one.
- R1-5: the orchestrator contract buried the lens's product in the exact
  case it was written for — SKILL.md forbade falling back to the PR
  description and the verify brief downgraded fidelity findings lacking
  issue evidence to low confidence (terminal-only). Carve-outs added in
  critical rule 4, the Step 2 context paragraph, and the verify brief: a
  replay finding quotes the PR's own narrative as its evidence, judged
  as the PR's claim about what the change prevents, not as ground truth.

* fix(review): route the no-step-changed replay outcome to a finding, never the receipt

Round-2 review feedback, all four findings addressed:

- R2-1 (Critical): round 1's fourth evidence item routed the replay's
  no-step-changed outcome INTO the scope-empty receipt while the bullet
  above mandates it as a Critical — two mutually exclusive return
  shapes, and a receipt contributes nothing to the verdict, so the
  mandated Critical could dissolve. The contract now routes explicitly:
  no step changed = a findings return; the receipt carries only the
  benign outcomes (the step the replay saw change, or an explicit
  statement that the description narrates no incident).
- R2-2: four load-bearing clauses pinned — the replay's Critical
  severity, 1c's record-as-mechanism severity condition, the
  distinguishability sentence, and the verifier's no-downgrade clause.
- R2-3: the orchestrator-facing copies of Agent 0's return contract
  (the whiff-check parenthetical and the roll-call example) updated to
  the new shape, so a skipped replay cannot pass as the old three-item
  receipt the prose told the orchestrator to accept without relaunch.
- R2-4: SKILL.test.ts revert guards for both SKILL.md copies of the
  incident-replay carve-out, following the rule-4 guard's pattern.

* fix(review): complete the receipt example and pin the last unpinned clauses

Round-3 review feedback, all three findings addressed:

- The roll-call example restores the 'not a bugfix' evidence item the
  round-2 rewrite dropped — it now models all four receipt items, so an
  orchestrator shaping its Step 6 line on it cannot certify scope-empty
  for a bugfix PR without that determination asserted.
- The orchestrator-side copy of the R2-1 routing rule and the roll-call
  line are pinned in SKILL.test.ts's carve-out guard: reverting either
  restored the pre-R2-1 receipt standard while every brief-side pin
  stayed green.
- The TIME-axis pins gain the definition clause ('a record, not a
  mechanism') and the two-moments method — without them the severity
  rule names a split nothing defines.
2026-08-22 15:47:35 +00:00
Bob.qwencode
c10143a9c1
chore(release): v0.22.0 (#9736)
* chore(release): v0.22.0

* docs(changelog): sync for v0.22.0

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-22 15:23:02 +00:00
jinye
39378ac0a4
feat(serve): restore ask_user_question HITL on session load/resume (#9665)
* feat(serve): restore ask_user_question HITL on session load/resume

Keep a trailing unanswered question votable after daemon load/resume when --restore-ask-user-question is on, instead of closing it as a failed tool result.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): harden ask_user_question restore per review feedback

- acpAgent: defensive restore hint (no `!` lookup; accepts undefined
  session) + normalized id lookup on both cold return paths; session
  test doubles carry shouldHintAskUserQuestionRestore
- bridge: single maybeFireRestoreAskUserQuestionPrompt helper with the
  full admission-time busy predicate (pendingPromptCount +
  goalTurnActive), sync-throw try/catch, no-attached-client gate, fork
  suppression, and hasActivePrompt reflecting an admitted restore
  prompt; child-bound requests carry a suppress meta when the daemon
  already knows it will decline, keeping replay skip and re-hang
  aligned
- Session: restore prompt gated on the config flag; early bail before
  per-turn bookkeeping when history is not restorable; system reminders
  ride the post-answer message; restore turns no longer burn the
  active-todo reminder; a permission timeout on a restored question no
  longer persists the fabricated decline (transcript stays dangling for
  a later re-hang); continueLastTurn declines a restorable question;
  restorable detection reads peekLastHistoryEntry instead of cloning
  the full history
- history-replay-page: isInitialized() guard on the skip probe; dead
  paged-path skip wiring removed
- transcript-replay: skip set matches raw ids after dedup renames
- core: inline orphan-repair preserves the restored AUQ ids; the
  compression side query strips a trailing dangling functionCall; the
  CLI flag is honored only in ACP mode

* fix(cli): skip restore hint helper when the switch is off

Load/resume used to call shouldHintAskUserQuestionRestore on every Session, including test doubles that do not implement it. Short-circuit on argv first so the default-off path stays independent of the restore-only API.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 14:19:18 +00:00
jinye
af25c45e80
fix(cli): Recover sessions across archive races (#9513)
* fix(cli): report a conversation directory deleted mid-inspection as already gone

A child deleted between the lstat and the realpath, or a root that
vanished mid-inspection, was rewritten as 'identity_changed' and then
surfaced as 'Live conversation directory must be an owned direct child'
— a plain Error with no .code pointing at permissions and symlinks when
the directory was simply deleted. Restore the ENOENT-race -> false
contract of discardEmptyConversationDirectory (QwenLM/qwen-code#9489,
item 4).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): keep conversation metadata reads race-free and parent ids storage-aligned

Item 2 of QwenLM/qwen-code#9489: readExistingMetadata read the location,
read the metadata, then re-read the location and returned undefined on
mismatch, so an archive landing between the probes made lock-free
resolvers report a healthy session as session_not_found. Creation
metadata is immutable, so one tolerant read per state (active first,
then archived) decides deterministically; the location probes are gone
and a path-safety charset gate keeps the joined transcript path a
single segment.

Item 3: the parent-lineage gate required strict RFC-4122 v1-v5 ids
while the store resolves far looser names, so persisted parents written
by older builds (nil, v6/v7, agent-suffixed ids) turned loadable
children into SessionNotFoundError, and the -agent- allowance could
never resolve. Drop the shape gate and let storage resolution decide,
keeping only the self-reference rejection.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): let loads resolve both-states sessions and drop the pre-lock restore scans

Items 1 and 5 of QwenLM/qwen-code#9489.

Item 1: a session persisted in both active and archived states — left
behind by a crash inside archiveSessions — hard-failed ACP session/load
and session/resume with session_conflict while plain CLI --resume kept
loading the active copy. findSessionIdIgnoringCase now resolves the
requested spelling first (and a single both-states candidate) instead
of throwing, and assertSessionLoadable treats 'conflict' as loadable
from the active copy. Mutating surfaces keep refusing: unarchive still
conflicts via assertSessionArchived, and multi-runtime ownership
arbitration stays strict so a conflicted internal copy cannot claim a
session an ordinary workspace serves.

Item 5: both restore handlers ran findSessionIdIgnoringCase twice per
request — once as a pre-lock guard whose result REST discarded and the
ACP twin kept as a stale storageSessionId fallback consumed exactly in
the TOCTOU where the in-lock resolve returned undefined. The pre-lock
guards are gone (the in-lock resolve is authoritative and both handlers
now agree), the exact-spelling fast path removes directory scans from
the common case entirely, and the remaining scan uses async readdir so
a large chats tree no longer blocks the daemon event loop.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): preserve canonical restore conflicts

Canonicalize live task keys before resident bridge operations, and keep known case-conflict responses when the optional storage recheck fails.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): handle case-variant session follow-ups

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): preserve mixed-case live task identity

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): resolve canonical persisted session ids

Batch case-insensitive transcript lookups for multi-thread waits and preserve organization metadata when live and persisted session IDs differ only by case.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): preserve canonical session restore state

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): complete canonical session reads

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): address canonical review findings

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): preserve aliased session organization

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): close canonical session review gaps

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): close canonical task ownership gaps

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): keep case twins distinct across session pages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): isolate alias verification failures

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(integration): align both-states transcript expectation

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): preserve mixed-case session ownership

Arbitrate noncanonical live task IDs across workspace runtimes and retain the newest legacy organization alias only for uniquely persisted sessions.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): preserve live task ownership during refresh

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): handle session alias races

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#9513)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(e2e): normalize generated session ids

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): narrow PR 9513 to restore regressions

Drop the review-driven mixed-case expansion and retain only the five regressions tracked by #9489.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): complete active transcript conflict recovery

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): align session conflict assertions

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): align transcript conflict e2e

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#9513)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-22 14:01:59 +00:00
Shaojin Wen
2a99e84169
fix(review): clear the deferred Round-5 findings from the Aone write path (#9604)
* fix(review): clear the deferred Round-5 findings from the Aone write path

The full cleanup of #9579 — the 29 Suggestions deferred from round 5 of
the /review bot on #9491 under the ~5-round rule (Criticals-only from
that round on). One item (the GH_HOST setGhHost assertions) was already
landed with the round-5 Critical fixes; the rest are implemented here.

Write-path fixes:
- A shaped-but-empty --host refuses with its own shape (host-flag-empty)
  instead of collapsing to the unbound refusal the flag was the remedy
  for — the agent re-run loop the refusal wording exists to break.
- An invalid host (recorded verbatim or flag-typed) refuses in the
  exit-3 shape naming the offender and its origin, instead of setGhHost's
  TypeError escaping runSubmit as a failed command.
- A flagless gh post whose nothing-bound routing would inherit an
  ambient GH_HOST pointing at canonical Aone refuses actionably
  (ambient-gh-host-aone) instead of failing opaquely after compose ran.
- The shared authorisation gate no longer reads an absent host as a
  github.com claim for callers whose routing follows the recorded
  binding (submit): the ordinary flagless publish of a GHE-recorded
  review passes, while publish-assets keeps the strict comparison.
- Mid-batch drift disclosure rides the partial-post shape too
  (headMovedDuringPost on AonePartialPostError, warned from submit's
  partial branch), and the post-batch re-read is tri-state: a failed
  re-read leaves headMovedDuringPost undefined and submit discloses
  "could not re-verify" instead of a false all-clear.
- The Aone success JSON surfaces postedCommentIds/summaryCommentId —
  the audit the partial shape carries and the gh receipt records.

Docs and contract fixes:
- The context-unavailable cap wording now says what it does (keeps an
  Approve verdict at Comment; a Request-changes verdict still posts)
  in the user docs and both SKILL.md sites.
- The head-drift bullet is qualified by the per-review restart bound —
  spent on Aone there is no submit-at-reviewed-SHA fallback; report and
  leave the rest to the user.
- Step 9's Posted: contract admits the no-link note the Aone fallback
  prescribes.
- The --host help text spells both canonical Aone hosts out.
- The provider design doc's Phase-3 "refuses" sentence is marked
  superseded.

Test hardening (unfalsifiable pins made falsifiable):
- ensureAoneAuthenticated ordered before the writes; setGhHost ordered
  before the gh write; the a1 path never touches the gh host state.
- Live-probe cells for the explicit-flag precedence, the unbound
  refusal, and the fast-path hostless refusal; the recorded-binding-
  outranks-probe fixture driven through submit's real gitOpt seam.
- submit.test.ts mocks ./lib/git.js (no real git spawned in the vitest
  cwd), isolates the cross-session suite's recording store via chdir,
  and pins the newest-wins ordering when two recordings of one PR carry
  different hosts.
- Producer-side 'refusing to post:' prefix pins, the RC-Note count
  source pin, the contextUnavailable:true gh-path pin, and the floor
  recovery's callerHost pin.

* fix(review): address round-1 findings on the Aone write path (#9604)

* fix(review): address round-2 findings on the Aone write path (#9604)

Extract one refuse helper for submit's seven exit-3 refusal shapes
(sibling publish-assets precedent), align the Aone pre-write refusal
prefix with the other refusal paths, and pin the invalid-host remedy
of the flag/origin arms positively — the recorded arm's absence pin
alone let a ternary-collapse mutant ship green.

* fix(review): address round-3 findings on the Aone write path (#9604)

Make submit's exit-3 refusal terminal: refuse now throws a SubmitRefusal
that runSubmit's single catch renders into the refusal shape (stderr
line, posted:false JSON, exit 3), so a gate that says no cannot fall
through toward the write — the helper previously returned and relied on
every call site adding its own `return;`. Also extract the post-batch
MR-head re-read, duplicated between submitAoneReview's partial-post and
success paths, into one helper.

* fix(review): address round-4 findings on the Aone write path (#9604)

* fix(review): address round-5 findings on the Aone write path (#9604)

* fix(review): address round-6 findings on the Aone write path (#9604)

* fix(review): resolve merge-conflict residue in the review skill (#9604)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-08-22 13:27:51 +00:00
Shaojin Wen
4b014c96e5
fix(review): stop shedding the convergence observation first (#9715)
* fix(review): stop shedding the convergence observation first

The convergence paragraph is the only thing a posted review says about the
SHAPE of the loop rather than about the diff, and it was rank 0 — the second
block the body-budget ladder sheds, right after the mechanism-health note.
The reasoning was that an advisory paragraph decides nothing, so it should
yield before the deferral list and the not-reviewed disclosures.

The arithmetic does not support that ordering. Rendered bilingually the
paragraph is 603 characters when only the volume signal fired, 1,510 with
three recurrence clusters, and 2,372 with the clusters, the evidence caveats
and the land reading together — against a body budget of 56,830. Shed
second it can pay for at most 4% of an overflow, so any overflow larger than
the paragraph itself spent it and then went on to spend the deferral list and
the disclosures anyway. On the rounds this fires on — the high-volume ones,
which is the whole point — that is the normal case: the author lost the
reminder AND the disclosures.

It is rank 3 now, the last rank the ladder sheds. Still ranked, not untagged:
a body that genuinely cannot hold its blockers must drop an advisory, and
being ranked is what makes the trim notice name it when that happens. Ranked
last because it is the cheapest block to keep and the only one whose reader
is the pull request's author alone — the deferral list has a second durable
copy in the findings artifact, the disclosures are restated in the terminal
report, and the mechanism-health note above it is written for the operator,
who has the `HEALTH:` line.

The test that pinned the old order is replaced by two that pin the new one:
one sized to the window where the ladder sheds the disclosures and stops,
asserting the paragraph survives and the notice names what actually went; one
sized a rung further, asserting the paragraph goes last and is named when it
does, before the hard cut.

Mutation-verified: restoring `trim: 0` reddens both; making the block
untagged reddens the last-and-named test (and the existing terminal-copy
test); removing the rank's name from RANK_NAMES reddens only the naming
assertion.

* test(review): finish the rank move in the comments, and re-centre a constant

Two hygiene items the review caught on the trim-rank change.

A test title and its comment still said the convergence paragraph is the
first rank the ladder sheds — the sentence the change makes false. Three
other copies of that wording were updated with the move; this one was
missed.

And `keeps the terminal copy on the round that actually sheds the paragraph`
was sized at 55,850. Reaching a body that dropped the paragraph now means
sizing past every other rank, and the window that does so without also
truncating runs 55,825–56,350 — so the constant sat twenty-five characters
above its own floor, with no note saying it was tuned at all. It is 56,100
now, near the middle, and carries the same retuning instruction as the two
order tests. It also asserts the body was not truncated, so a future retune
that overshoots reads as a failure rather than as a pass for the wrong
reason.

Measured, not guessed: sweeping the blocker size in 25-character steps puts
the shed boundary between 55,800 and 55,825 and the truncation boundary
between 56,350 and 56,375. Dropping the constant back below the window
reddens the test, so it still reaches the case the terminal copy exists for.

* docs(review): finish the rank move where the rationale actually lives

Six comments still asserted that the convergence paragraph is the first thing
the overflow ladder sheds. The reviewer named five; a sweep found a sixth,
and two of the six were already inaccurate before this branch touched
anything.

Corrected:

- the `convergence` result-field doc — "sheds this paragraph first"
- the deferral block's own rank comment — "the first thing to yield", which
  ranks -1 and 0 had both preceded since before this branch
- the not-reviewed disclosures — "and before nothing else"
- the `CONVERGENCE:` stderr line's rationale — "the first thing the overflow
  ladder sheds"
- `save-artifact.ts`'s allow-list — "the ONE clause the overflow ladder sheds
  first"
- the body-budget suite's own statement of the policy under test, which
  listed two of the four ranks

Left alone, and verified correct: every "sheds first" attached to the
mechanism-health note, which is rank -1 and genuinely first
(`save-artifact.ts`, `save-artifact.test.ts`, the health-note terminal-copy
test), and the `keep`-ordering comment about the tail cut, which is not
about `trim` at all.

Each corrected site keeps its own reasoning — durability, the artifact, the
terminal copy — and states the ordering only as far as that reasoning needs,
rather than restating the whole argument a sixth time. Six copies of one fact
is what let it drift; the argument for the ordering lives at the convergence
block and the others point at it.

The durability rationale gets stronger, not weaker, and the comments now say
so: a body that sheds rank 3 has already shed every other rank, so the
terminal and artifact copies are the only ones left exactly when they fire.
2026-08-22 13:24:45 +00:00
Shaojin Wen
0c36e5093a
feat(review): close Aone residual gaps — composeUrl, test-plan routing, a1 version floor (#9624)
* feat(review): close Aone residual gaps — composeUrl, test-plan routing, a1 version floor

The three residuals #9619 tracks together, one pass:

- composeUrl joins the platform reader: GitHub composes the PR-page URL
  from the routed host (deterministic grammar, no API call); Aone is
  reader-backed — the platform's own detailUrl, never assembled, since
  the nested-group owner/repo collapse can name a different repo.
  submit fills a receipt that carries no url through it on both
  platforms, so the skill's prose fallback shrinks to the coordinates
  relay for the one case the reader cannot serve.
- test-plan's body fetch routes through the platform reader: the MR
  description on Aone (already in the reader's fetch metadata — no new
  API surface), so the Test Plan check runs on Aone targets instead of
  going unchecked on every run.
- ensureAoneAuthenticated enforces the a1 version floor design-doc Q1
  asked about — 0.1.90, the version the platform facts were probed
  against — in presence → floor → auth order, each with its own remedy
  message; unreadable versions are disclosed on stderr and fail open.

Verified: ~590 targeted unit tests, tsc/eslint/prettier clean, build +
bundle green, and a CLI smoke that refuses a fake stale a1 at the floor
while a fake fresh one passes the gate.

* fix(review): apply round-2 review on the Aone residuals

All six round-2 suggestions on #9624, probe-verified and pinned:

- R1-1: the version-probe fail-open now discloses the CAUSE — the
  extraction mirrors the whoami catch (first non-empty line past the
  execFileSync preamble), so segfault / unsupported flag / permission
  failures stay distinguishable instead of one constant preamble line.
- R1-2: aoneReader.composeUrl discloses a failed lookup on stderr
  before degrading to '' — every other fail-open in the provider
  discloses, and the coordinates-relay case must stay distinguishable
  from an environment fault.
- R1-3: one home for the PR-page host spelling — normalizeGhHostForUrl
  in lib/gh.ts, shared by compose-review's comment anchors and the
  reader's composeUrl, so a `--host GHE.Corp:443` run can no longer
  print two textual spellings of the same PR page; non-default ports
  survive.
- R1-4: submit no longer re-queries the reader when the Aone receipt
  carries no webUrl — detailUrl is a stable MR attribute and the
  pre-write drift-gate read already carried it, so the second fetch
  could only block on the flaky state that lost the field. The empty
  receipt rides the coordinates relay; the reader keeps composeUrl as
  the canonical seam.
- R1-5: the Aone body-fetch route runs the same ensureAuthenticated
  gate every other a1-backed flow runs first — a standalone test-plan
  on a missing/stale/logged-out a1 now fails exit 1 with the
  install/upgrade/login message instead of exit 0 with the generic
  note. The GitHub arm keeps its historical degrade.
- R1-6: the handler wiring (the Aone fix's integration point) is
  pinned by handler-level tests — an Aone --host must route the body
  through the reader with the gate first, and a refused gate fails the
  command before any fetch.

SKILL.md's Posted paragraph, its revert-guard pins, and the design-doc
bullet follow the R1-4 semantics. Verified: tsc/eslint/prettier clean,
759 targeted cli tests + 23 SKILL guards green.

* fix(review): apply round-3 review on the Aone residuals

* fix(review): apply round-4 review on the Aone residuals

* fix(review): fail closed on unknowable host in composed receipt link (#9624)

* fix(review): route explicit GHE-family hosts to the GitHub reader

Platform detection selected Aone on ANY *.alibaba-inc.com host for
explicit --host/--remote signals, but a family host that is not the
canonical pair (ghe.alibaba-inc.com is the live example) is a GitHub
Enterprise instance — such a review authenticated against a1 and read an
unrelated same-numbered Aone MR instead of the GitHub PR body (and
test-plan additionally gated on a1 auth first). Explicit signals now
select Aone only via the canonical pair (code./gitlab.
alibaba-inc.com) — the same canonical-only rule the write gate has
always applied — while the family predicate survives on the
no-explicit-signal cwd-origin fallback. parse-args stops refusing
/pull/ URLs on GHE-family hosts (the same predicate misapplied: those
are real GHE PR URLs), and the five --host describe texts now name the
canonical pair.

Pins: registry.test.ts flips the GHE explicit-host expectation to
github, adds the explicit-remote and canonical-port arms, and keeps the
family cwd fallback; detection-side 592 + write-side 322 tests green.

* fix(review): fail closed on family-only /codereview/ URLs

AONE_CR_URL_RE captures the whole *.alibaba-inc.com family (shape-first
grammar), but a family-only host is a GHE instance that serves no
/codereview/ page: accepting its URL as a live target would let
detection route the explicit GHE host to GitHub and aim fetch/submit at
GHE PR #<id> — a target the supplied URL never named as a valid GHE
resource. The classifier now gates the aoneMatch branch on
isAoneCanonicalHost, so non-canonical /codereview/ inputs stay
invalid-url, mirroring the /pull/-on-canonical-Aone refusal. The mirror
arm is pinned too: a /pull/ URL on a family-only host parses as the
real GHE PR target it is.

---------

Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-22 12:55:25 +00:00
易良
b455bad5e5
refactor(cli): keep acp-integration off serve internals (#8084) (#9144)
* refactor(cli): keep acp-integration off serve internals (#8084)

The dependency direction set in #8084 regressed: native Live Voice
(a5c637b749) added four acp-integration imports of serve/live modules,
because nothing in the repo enforces the boundary the issue defines.

Ownership, measured by consumer rather than by directory:

- capture-screen-context, live-task-tools, live-speak-to-user and
  live-backend-instructions each have exactly one production consumer,
  acp-integration/session/Session.ts, and import nothing from serve/.
  They move to acp-integration/live/ with their tests.
- conversations/session-source is shared by acpAgent and four serve
  modules, has no imports, and takes its reader as a parameter, so it
  moves to runtime/live-session-source.ts alongside the other neutral
  contracts. Renamed because every symbol in it is Live-specific.

Adds a no-restricted-imports rule for acp-integration/** so the next
feature spanning both surfaces gets a lint error pointing at runtime/,
rather than silently reopening the criterion.

No behavior change: moves, import rewrites, and the lint block.

* fix(cli): harden the acp/serve boundary guard (round 2)

- Flag the bare '../serve' directory specifier, which resolves to the
  serve/ barrel and skipped the trailing-segment group patterns (also
  added to the utils/ guard for symmetry).
- Extend the same boundary to runtime/, the layer the rule directs
  authors to, so the #8084 coupling cannot reform one hop away.
- Cover dynamic imports: no-restricted-imports never visits
  ImportExpression, so a no-restricted-syntax selector now enforces the
  boundary for await import('../serve/...') too. The acp-integration
  block moves after the general TS block (flat config lets the last
  matching block win per rule) and restates its no-restricted-syntax
  selectors so the override drops nothing.
- Document that CI lint is the enforcement point; no fixture test pins
  the block.

Verified: synthetic fixtures for all three violation shapes are
rejected; full npm run lint passes with no live violations.

* test(cli): pin serve boundary lint rules

* test(cli): close serve boundary lint gaps

* fix(lint): close serve-boundary entrances and harden the guard

- reject computed dynamic-import sources (concatenation, new URL) and
  type-level imports fail-closed; rounds 2-5 each demonstrated a new
  per-spelling regex entrance, so non-literal forms are blocked outright
  (R4-1)
- rewrite the boundary patterns without nested quantifiers; the previous
  shape backtracked exponentially (~4x per two ../ segments, lint-time
  ReDoS) (R5-2)
- build the three guarded override blocks no-restricted-syntax arrays from
  one shared helper so flat config last-wins cannot silently drop selectors
  (R5-3)
- pin the bare-directory barrel specifier in fixtures (R5-4) and add a
  string-throw probe pinning the restated selectors in the overrides (R5-5)
- replace the **/serve* static globs with enumerated relative depths so
  third-party serve-named packages are never flagged (R5-7)

* fix(lint): correct TSImportType selector path and computed-template handling

- read the type-import specifier at argument.literal.value: @typescript-eslint
  wraps it in a TSLiteralType, so argument.value was dead code and the old
  fail-closed TSImportType selector over-matched every type-level import
  (37 errors in files this PR never touches) (round-6 Critical)
- reject computed template literals (templates containing expressions)
  fail-closed; pure-literal templates stay covered by the quasis pattern
  selectors — the old blanket TemplateLiteral exemption contradicted the
  fail-closed comment above it (round-6 Critical)
- give the fail-closed selectors a distinct message: computed sources
  cannot be checked against the boundary, which is not the same policy as
  importing serve/ (round-6 suggestion)
- pin the depth-enumeration loop beyond depth 1 with a depth-2 fixture,
  pin the fixed type-import selector with a negative typeof-import control,
  and pin the computed-template fail-closed path (round-6 suggestion)

* fix(lint): close the remaining round-6 serve-boundary entrances

Complements the previous commit (which fixed the TSImportType selector
path and computed-template fail-closed) with the R4-1 entrances it left
open, each pinned by a fixture:

- percent-encoded segments (`../%73erve/index.js`): Node percent-decodes
  segments when mapping the resolved URL to the filesystem, so raw-text
  patterns cannot see through them — any `%` in a guarded-tree specifier
  is now rejected with a dedicated message.
- static traversal twins: the pattern regexes now run over static
  ImportDeclaration/ExportNamedDeclaration/ExportAllDeclaration sources
  too, closing `import './../serve/x'`, `import '../runtime/../serve/x'`,
  and `import '..//serve/x'`, whose dynamic twins were already blocked.
- leading literal segment: a traversal-anywhere pattern catches
  `import('foo/../../../serve/x')` past the dot-slash anchor.
- vitest module-loading calls (vi.mock/doMock/importActual/importMock)
  resolve and load the real module, so they get the same patterns plus
  fail-closed coverage for computed arguments.

* fix(lint): cover vitest serve-boundary calls

* fix(lint): close the round-7 serve-boundary entrance classes

R4-1 round-7 interim hardening (the durable specifier-resolving custom
rule remains tracked separately):

- case-variant spellings (../Serve/...): every pattern, percent and
  quasis attribute regex now carries the i flag, covering the dynamic,
  static, vi.*/vitest.* and TSImportType arms.
- ?query/#fragment suffixes: rejected alongside % in all eight
  specifier shapes (bundlers/Node strip them when resolving, so
  '../serve?x' reaches the same module as '../serve').
- percent-encoded pure-template vitest calls: added the missing
  arguments.0.quasis.0.value.cooked twin to the reject list.
- root-absolute and file: literal specifiers: fail-closed rejected in
  every literal shape (guarded trees sweep verified clean of both).
- createRequire: its source modules ('module'/'node:module') are
  flagged in guarded trees, since the alias escapes the
  callee-name="require" arm and Node >=22 require(esm) loads serve/.

Each entrance class is pinned by a fixture case (18/18 green through
the real ESLint API); the three guarded trees lint clean with the new
arms.

* refactor(lint): resolve the serve boundary by resolution, not text (#8084)

R4-1 round-8 decision (maintainer-approved option a): replace the
spelling-by-spelling regex/glob matrix with a local resolution-based
ESLint rule (eslint-rules/no-serve-boundary-cross.js).

Eight review rounds each demonstrated a new spelling escaping the text
matrix (data: URLs, percent-encoding, traversal through a leading literal
segment, baseUrl bare specifiers, createRequire/getBuiltinModule,
TSImportType, aliased vitest loaders, Worker/fork), because every spelling
is just another way to NAME the same target. The new rule resolves each
import-like specifier against the importing file and reports anything
landing inside packages/cli/src/serve/:

- relative specifiers resolved against the importing file
- baseUrl bare specifiers resolved against packages/cli (tsconfig baseUrl
  makes `src/serve/...` reachable — the round-8 entrance text never saw)
- file: URLs resolved to concrete paths (case-insensitive, whitespace-trimmed
  scheme detection, since the URL parser normalizes both)
- vitest loaders matched alias-proof (v.mock / destructured importActual);
  only specifiers resolving INTO serve/ report
- child_process.fork checked; spawn deliberately not (first arg is an
  executable, not a module)
- fail-closed on statically-unresolvable sources: computed sources, data:
  URLs, traversal-bearing bare specifiers, node:module imports,
  process.getBuiltinModule
- case-insensitive path comparison (Serve/ loads serve/ on
  case-insensitive filesystems)

Fixture suite reworked to resolution semantics: several round-4..7 fixture
depths corrected to spellings that genuinely resolve into src/serve (the
old depths resolved to packages/cli/serve, outside src/serve, and were
only caught by text matching); new pins for every round-8 entrance and for
the Codex self-review Criticals (aliased loaders, uppercase/whitespace URL
schemes, spawn not an import source). 26/26 pass; guarded trees and the
full cli src lint clean (zero false positives).

Removed: relativeServeImportPatterns, restrictedServeImports,
serveDynamicImportPatterns, serveGuardSyntaxRules and the per-spelling
selector/percent/absolute/createRequire special cases.

* fix(lint): drop the dead serveGuardSyntaxRules helper

The resolution-rule commit removed the mechanism but left the
serveGuardSyntaxRules helper behind — unused (no-unused-vars) and
referencing the already-deleted restrictedServeDynamicImports (no-undef),
which failed CI's repo-wide eslint. The guarded trees inherit
restrictedRequire + restrictedStringThrow from the general TS block, so
nothing is lost.

* fix(lint): close serve boundary resolver gaps

* fix(lint): address serve boundary review suggestions

- R9-2: move the new-URL-with-import.meta check into the NewExpression
  visitor with the real MemberExpression base shape; the CallExpression
  placement was unreachable and standalone new URL(...) reported nothing
- R9-3: report module-builtin entrances via the moduleBuiltin messageId
  instead of the self-contradicting failClosed remediation text
- R9-4: match the destructured fork(...) spelling, not just
  child_process.fork(...)
- R9-5/R8-2: fixture pins for re-exports, Worker, fork, require,
  vi.doMock and vi.importMock entrances
- R9-7: pin the false branch of static-template concatenation (pure
  template literals resolving outside serve stay allowed)
- R10-3: filter the third-party serve-named package pin by ruleId so a
  failClosed false positive also turns it red
- R13-2: pin resolution detections on the serveBoundary messageId so
  inside-detection degrading to blanket fail-closed cannot ship green

* fix(lint): close round-11 serve boundary gaps

Critical fixes:
- '#name' package-imports specifiers sailed through: stripUrlSuffixes
  splits on '#' before the fail-closed check saw it, so the branch was
  dead code and '#s' classified outside. Check '#' before suffix
  stripping (fixture pins both entrances).
- scheme detection used JS trim(), which keeps non-whitespace C0
  controls — '\x01data:…' slipped past while Node's URL parser strips
  C0-or-space at the edges and loaded it. Detect schemes on the
  WHATWG-normalized form (fixtures added).
- eval("(0,eval)"/globalThis.eval spellings included) and new Function
  can embed import('…') the rule cannot resolve — fail closed like
  computed sources; no-eval/no-new-func are not enabled in the shared
  config and the guarded trees contain no such calls.
- backslashes normalize to '/' under Node's URL-based ESM resolution
  (file: URLs are special), so '..\\serve\\x.js' loaded serve/ on
  posix while the rule saw a bare specifier. Normalize backslashes
  before classification (fixture added).

Hardening + pins:
- fork/Worker arms match object-agnostically (namespace/default-import
  spellings no longer evade); Worker skips new URL(spec, import.meta.url)
  arguments so the URL arm owns them (no more fail-closed false positive
  on the canonical construct, no double report on serve targets).
- new TSImportEqualsDeclaration visitor: import x = require('../serve/…')
  emits a working createRequire shim under tsc NodeNext.
- isProcessObject accepts computed properties (globalThis['process']) and
  the Reflect.apply arm accepts computed getBuiltinModule; the three
  getBuiltinModule arms collapse into one via a shared property matcher.
- vitest loader names lifted into a module-level constant; corrected two
  stale comments (fail-closed branches; R5-5 probe description).
- fixtures: root-absolute/file: inside verdicts (repoRoot, messageId),
  fork member arm, template cooked values, bare vitest loaders, dynamic
  bare-'module' entrances, outside-serve negatives for URL/Worker/fork/
  require. Suite 44/44.

* fix(cli): restore live session source import

* fix(lint): clear the two lint errors breaking CI on the boundary rule

Follow-up to the round-11 batch, which landed without running the
repo lint:
- the C0-edge-strip regex legitimately contains control-character
  ranges (it mirrors the WHATWG URL parser), so disable
  no-control-regex on that line with a rationale comment instead of
  rewriting the range.
- drop the unused UTILS_FIXTURE constant from the boundary tests
  (no fixture lints a utils/ file).

eslint clean on both files, boundary suite 44/44, prettier clean.

* fix(lint): close bounded serve boundary gaps

* fix(lint): complete the round-12 boundary escape closures

Extends the previous commit (which canonicalized the serve/baseUrl
comparison sides, added staticMemberPropertyName, and closed the
Function-call and Worker-eval-option shapes) with the remaining
round-12 review surface — every demonstrated spelling probed before
and after:

- Callee identity is now shape-tolerant end to end: rightmost-segment
  object matching (nested member objects like globalThis.vi / x.cp no
  longer evade the object-agnostic arms), renamed loader bindings
  resolved from the import declarations (fork-as-f, Worker-as-W),
  Reflect.apply/construct unwrapped for guarded targets (fork included),
  Function.prototype.call/apply/bind indirection handled (.call unwraps
  with shifted args; .apply/.bind fail closed), and the
  SequenceExpression unwrap applied uniformly instead of eval-only.
- The string-code execution class fails closed beyond Function/eval
  direct calls: .constructor property chains (({}).constructor.constructor,
  (function(){}).constructor, AsyncFunction variants), eval.call/apply,
  and the node:vm surface (runInThisContext / runInNewContext /
  runInContext / compileFunction / new vm.Script, scoped to vm imports).
- The Worker eval option fails closed unless eval is statically false
  (a dynamic option or non-object second argument is unverifiable), and
  the URL arm's import.meta base restriction reports failClosed on the
  construct.
- Fixtures pin each class: shape variants, renamed bindings, Reflect
  indirection, call/apply/bind, the string-code family (incl. messageId-
  specific failClosed pins for the eval:true Worker and non-url
  import.meta bases), bare-directory/baseUrl query-suffix spellings, and
  outside-serve allow pins for the export/import-equals arms.
- expectServeBoundaryError now filters on the rule id (all three
  messageIds contain 'serve'); the divergent substring negative pins
  move to expectNoBoundaryHits.

Suite 52/52; guarded trees lint clean (no false positives from the new
arms); eslint + prettier clean.

* fix(lint): close the round-12 reviewer escape classes (#8084)

Ten Criticals plus five hardenings from the round-12 review, every
class probe-verified before and after:

- R12-1: Worker eval-option analysis now matches runtime object-literal
  semantics — the LAST eval key wins (duplicates included), an options
  object without eval defaults to false (specifier path, no
  over-block), and a spread after the last literal eval is
  unverifiable — fail closed.
- R12-2: sequence unwrapping is now a uniform invariant — recursive on
  callees in both visitors and applied to object expressions
  (rightmostObjectName/isProcessObject), closing (0, require).call,
  (0, (0, require)), (0, process).getBuiltinModule, (0, vm).* and
  new (0, vm).Script.
- R12-3: call/apply/bind indirection is complete — Function/constructor
  forward code (unconditional fail-closed), chained indirection
  (x.call.call) fails closed instead of falling through, and vm exec
  names plus the fork/vitest alias sets resolve.
- R12-4: Reflect.apply/construct target lists mirror the direct-call
  arms — Function (incl. member spellings), the vm exec/Script surface,
  and the vitest loaders (member and identifier targets).
- R12-5: alias sets populate in a pre-pass over the module body — ESM
  imports are hoisted, so use-before-import now resolves like the
  import-first direction.
- R12-6: renamed destructured vitest imports resolve through a new
  vitestLoaderAliases set.
- R12-7: a named guarded global (process/globalThis/global) carrying an
  opaque computed key fails closed (process-family keeps the dedicated
  moduleBuiltin message); object-agnostic arms keep their documented
  residue.
- R12-8: .constructor fails closed on variable bodies and expression
  templates; statically non-string literals keep the pass-through.
- R12-9: inline lazy vm imports ((await import('node:vm')).*) count as
  vm objects in the exec and Script arms.
- R12-11/12/13: checkSource skips statically non-specifier arguments
  (no unactionable advice for env objects), the URL arm owns
  new URL(spec, import.meta.url) on every entrance (no over-block, one
  report on the serve form), and the module builtin reports
  moduleBuiltin on every entrance.

Test hardening: R12-10 normalizes the repoRoot pin to forward slashes
(Windows merge-gate determinism), R12-14 pins the four mutation
survivors, R12-15 pre-cleans and catch-cleans the baseUrl symlink
links. Suite 65/65; guarded trees lint clean.

* fix(lint): repair corrupted files from the git-API blob upload

The previous commit (552bc7c8f) was pushed via the GitHub git API with
`-f content=@file` blob payloads that GitHub stored corrupted (9-byte
binary blobs), breaking the eslint config load (SyntaxError) and the CI
Test gate. Re-upload both files with JSON --input payloads whose blob
SHAs match the local git objects byte-for-byte (8270a0d2 / 5a0ebaa2).
No content change beyond restoring the intended files; suite 65/65.

* fix(lint): close the round-13 serve-boundary escape classes (#8084)

- re-normalize backslashes AFTER percent-decoding so %5c/%5C cannot
  reintroduce a traversal the pre-decode normalization missed
- on realpath ENOENT canonicalize the deepest existing ancestor and
  re-append the missing tail, so symlinked-ancestor checkouts fail
  closed instead of open
- Worker eval-option scan: treat an unresolvable computed key like a
  spread (unknown), fail closed on a non-computed __proto__ prototype
  unless it is statically null, and resolve quoted string-literal keys
- carry the opaque-key fail-closed check through composed callees:
  one hop below .call/.apply/.bind, as a Reflect target, and on the
  getBuiltinModule object side, with the process-family message

Pins all four classes with executed fixtures plus negative controls;
the guarded trees stay lint-clean.

* fix(lint): close the round-13 binding-hop and callee-opacity escapes (#8084)

* refactor(cli): simplify ACP serve boundary guard

* fix(lint): close the round-21 contract pins and bare-barrel escape (#8084)

* fix(lint): close dynamic-import and js-file holes in the acp/serve guard (#8084)

* fix(lint): make acp/serve dynamic-import guard case-insensitive

The no-restricted-syntax selector for dynamic import() of serve/ was case-sensitive, so a macOS case-variant specifier (`../Serve/...`) would resolve to the daemon barrel without tripping the guard. Add the /i flag and cover case-variant plus computed-specifier behavior.

---------

Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
2026-08-22 12:39:08 +00:00
Shaojin Wen
fcc1dfb123
feat(review): report the address a drive's service actually bound (#9655)
* feat(review): report the address a drive's service actually bound

A port is a request, not a fact. Handed one that is taken, `qwen serve`
prints `port 8931 is in use, trying 8932...` and listens on the next.
A verifier that goes on addressing the port it asked for then reads a
different, stale process for the rest of the run — its readiness probe
passes against whatever is squatting there, the drive completes, and
every number in the witness is about the wrong daemon. Nothing in the
report says so, because nothing in the report knew: `drive` had no port
handling at all. Measured during a daemon verification, it cost a full
cycle before the readings stopped making sense.

`--capture name=<regex>` reads named facts back out of the run's own
output into `captured`, the bound address first among them. Four
choices in it are the ones that matter:

- It reads the UNTRIMMED log. `trimCapture` keeps the tail and a
  service prints its address at the head, so capturing from the
  report's `output` would lose exactly the value this exists for, on
  the loudest runs — the ones most likely to need it.
- A pattern that never matched is `null`, never `''`, and the note
  NAMES it. That is the moment a witness is about to quote a value the
  run never produced, and the reader has to know which one.
- A malformed pattern rejects the whole set before anything starts.
  Silently dropping the bad entry would leave a missing key beside the
  good ones, which reads as "the service never printed it" — the one
  meaning `null` is reserved for. Finding out after a 300-second drive
  costs the drive.
- Captures are taken on every outcome, not only `completed`: a drive
  that timed out still bound its port, and that address is often what
  explains where the rest of it went.

The verify brief carries it too. A capability taught only where the
verifier does not read is inert — the lesson from #9445's first review
round — so the brief now says to bind ephemeral where the service
allows it and quote the captured address rather than the one on the
command line.

Refs #9446.

* fix(review): correct drive capture examples and bound its outputs (#9655)

* fix(review): a declared capture group the match left unfilled is null

Round 1/2 review. `m[1] ?? m[0]` reads as "group 1, or else the whole
match", and for an OPTIONAL group that is a silent substitution rather
than a fallback: `(?:a(x))?b` against `b` matches with group 1 absent,
so the caller that asked what `x` matched received the whole match `b`
under the same name, with nothing in the report saying a different
question had been answered. Whether the pattern DECLARES a group and
whether group 1 happened to participate are different questions;
conflating them loses a value quietly, which is the one failure mode
this command exists to remove.

The declaration is now settled when the pattern is parsed — by matching
`<source>|` against the empty string, guarded, since it builds a second
pattern from the first — and a declared-but-unfilled group is `null`.
A pattern with no group still yields the whole match.

The docblock's `null`-versus-`''` claim was too strong beside that, and
the narrowing belongs to the pattern rather than the service: `''` means
the group captured zero characters, which a `*`-quantified group does
wherever it is anchored (`pid=(\d*)` returns `''` against `pid=abc`).
Said so, and named `+` as what a "printed nothing" test needs.

Also from the two rounds:

- The head-trim clause and the capture block contradicted each other in
  the note. `output` is trimmed at the head, `captured` is read before
  that trim, so a completed noisy drive said "early output is missing"
  directly beside a value that came out of the missing head — and beside
  a null the reader would reasonably blame the trim for. A scoping
  clause now says captures read the untrimmed log, covering the matched
  case (R1-8) and the missed one (D2-2) together.
- Four invariants the docblocks state were pinned by nothing: the
  unfilled-group rule, the empty-capture-is-a-measurement rule, the
  first-match rule against a log holding two, and the pattern-length
  cap's upper bound (only its lower bound was tested).
- The CLI seam has a test (D2-1). The handler casts `argv as unknown as
  DriveArgs`, which type-checks whatever the option is called, so a
  rename of either side silently disabled `--capture` end to end while
  every runDrive test stayed green and tsc exited 0. It now drives the
  real builder with a real flag string and asserts the value reaches the
  report the handler prints.

Each of these was checked by mutation: reverting the group rule, the
option name, the first-match rule or the scoping clause reds exactly its
own test, and restoring them returns 50 passed.

* fix(review): scope drive capture notes to the outcomes they describe (#9655)

* fix(review): stop the capture guidance from authorising a misattribution

@yiliang114's P1. Captures are extracted after the drive loop has ended,
so nothing in `captured` can reach a request the script already made —
and the brief told the verifier to quote the captured address in the
witness anyway. That combination is worse than not capturing at all: a
script that talks to 8931 while the service logs its fallback to 8932
reaches its sentinel, returns `observed: true` beside
`captured.baseUrl: …8932` with nothing contradicting it, and the witness
then attributes 8931's readings to the daemon on 8932. Before this
change the same run would at least have quoted the address it really
read.

The mechanism that prevents the wrong-process read lives in the script,
not in the report, and the brief now says so: derive the address from
the service's own output before the first request, bind ephemeral
wherever the service allows one so the fallback never fires, and let
`--capture` show that the address the script used is the one the service
printed. `null` is named as "never measured", explicitly not as
permission to fall back to the address on the command line. The recipe
is spelled out rather than described, because the failure it replaces is
one of omission.

The report carries the same caveat where it cannot be missed: `captured`
is documented as a RECORD of the run and never an input to it.

Verified rather than read: the recipe's `sed` and the `--capture`
pattern were run against real `listening on` lines, http and https, and
agree on the same value — which is the property that makes the capture
corroborate the script instead of competing with it. The rendered brief
was printed and re-read after the edit, and again after eslint caught an
unnecessary `\$` escape in it, to confirm `$BASE` still survives to the
agent verbatim.

No behaviour change: 340 passed across drive, agent-prompt and
run-skill-parity; tsc 0 errors; eslint clean.

* fix(review): make the taught bound-address recipe actually capture

Round 5 Critical, and it was mine: the recipe added last round sends the
service to a file of its own, and `drive` runs the script as
`bash <script> > <the drive log> 2>&1` while `extractCaptures` reads only
that log. So the service's `listening on` line never reached the capture.
Reproduced against the real `runDrive` before touching anything: a
faithful run of the recipe returns `completed`, `observed: true`, and
`captured.baseUrl: null`, under a note asserting the value "was never
measured" — while the service had printed it all along. A recipe that
cannot work is worse than no recipe.

The service's output has to reach two places, and the shape that does it
is a temp file the script greps plus a `cat` of that file before the
first request. Three details in it are load-bearing, and each is a
finding from this round:

- `mktemp`, not a file beside the code. `--cwd` is the reviewed worktree,
  and an untracked `svc.log` left there is inlined into the next capture
  of that tree as though the PR added it.
- `cat` BEFORE the request. Captures take the first match, so the
  service's own line wins over any response body containing one — a
  status endpoint quoting its own banner cannot forge the address.
  Verified with a service whose body advertises a different port.
- No `trap … EXIT` of the caller's own. The wrapper writes its
  completion sentinel from an EXIT trap and a second one replaces it;
  measured, a script with its own trap comes back `timed-out` with a null
  exit code having run perfectly. I was about to use one for cleanup.

Block-buffered stdout gets a line too — it is the one failure this shape
cannot fix, and the note's advice to raise `--timeout` cannot touch it.

The recipe is now pinned by a test that runs the brief's OWN text: the
script body and the capture pattern are extracted from the brief rather
than retyped, filled with a real service, and executed under the same
redirect contract `runDrive` imposes. No tmux is involved, because what
broke was the shell. Reverting the recipe to either broken form — the
one shipped last round, or merely dropping the `cat` — reds it, and
restoring returns 54 passed.

341 passed across drive, agent-prompt and run-skill-parity; tsc 0
errors; eslint clean, including two problems of my own the linter caught
in the new test.

* fix(review): re-read the drive log once its sentinel is observed

Round 6 Critical. The poll loop reads the log and then the sentinel, and
breaks on `completed` without reading again — while the wrapper writes
the sentinel from an EXIT trap, strictly after the script's last write
to the log. A final write landing between those two back-to-back reads
is on disk and not in the snapshot, a window one `readFileSync` of a
near-cap log wide.

The ordering predates this PR, where it cost a truncated tail in
`output` and read as a display artefact. Extracting `captured` from the
same snapshot turned it into a machine-readable measurement with a false
cause attached: `outcome: completed`, `captured.<name>: null`, and a
note asserting the pattern never matched — for a value the log on disk
contains. That is the shape this command exists to prevent, so the
escalation is the defect even though the loop is older than the diff.

Reproduced before changing anything, on a 7.9 MiB log with a real
writer process swept across the read window:

  before   trials=70  completed=70  stale-tail hits=1  (delay 248.72 ms)
           outcome completed, captured null, finalmetric=7 on disk
  after    trials=70  completed=70  stale-tail hits=0   same sweep

Every log write happens-before the sentinel write, so a read taken after
observing it is complete. Scoped to that branch alone: the other exits
stopped the run rather than watching it finish and have no such
guarantee to lean on, and the existing `existsSync` guard is kept so a
script that wrote nothing still reports an empty capture rather than
throwing.

No unit test: reproducing this deterministically needs the log mutated
between two reads inside one iteration, which wants a `readFile` seam
this command does not have, and drive.test.ts states that it never mocks
`node:fs`. A widened-race test hits ~1 in 70 and a flaky test is its own
defect. The measurement above stands as the evidence.

344 passed across drive, agent-prompt and run-skill-parity; tsc 0
errors; eslint clean.

---------

Co-authored-by: wenshao <nigolaschao777@gmail.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-22 12:30:10 +00:00
Shaojin Wen
7330a022cd
perf(review): give review agents their own subagent type (#9678)
* perf(review): give review agents their own subagent type

Review dimension agents were launched as `general-purpose`, which is the
only builtin subagent that declares no `tools` list. That sends it down
`AgentCore.prepareTools`' inherit-everything branch —
`getFunctionDeclarations({ includeDeferred: true })` — so every agent was
handed all 51 tool schemas, deferred ones included, and re-declared them
on every turn.

Measured with a recording endpoint on a 6-file / 115-line diff, driving
one real dimension agent through its four turns: 21,178 prompt tokens of
tool declarations per turn, of which the 35 `computer_use__*` schemas
alone were 11,011. A review names 13-14 such agents.

`review-agent` declares the six tools a dimension actually uses, which
takes the `getFunctionDeclarationsFiltered` branch that `Explore` and
`statusline-setup` already use. Same fixture, same launch prompt,
nothing else changed: 3,447 tokens per turn, and one agent's delivered
prompt fell from 139,013 to 55,733 (-59.9%) — about 1.08M tokens across
one roster.

The alternative — applying the deferral that trims the orchestrator to
subagents too — was measured at 84,537 and rejected: deferral does not
go below the ~14 core tools, and `revealDeferredTool` writes to the
registry the parent session shares, so one subagent's discovery would
rewrite the orchestrator's declarations and void its prompt-cache prefix.

* fix(review): correct the review-agent docs and pin its system prompt

Addresses the review on #9678.

The `REVIEW_BUILTIN_SUBAGENT_TYPE` docstring described a fallback that
does not exist. `AgentTool.execute` substitutes the default only when
`subagent_type` is omitted, and `loadSubagent` ends at `getBuiltinAgent`
with no default, so an unknown non-empty type fails loudly with
`Subagent "<name>" not found` rather than silently reverting. It also
claimed the literal is the only input to the branch choice; resolution
runs session > project > user > extension > builtin and builtin names
are not reserved, so a user-authored `review-agent` shadows this entry —
deliberate, since that is how any builtin is customised, but worth
documenting where the cost is explained. The same wrong mechanism was
repeated in a SKILL.test.ts comment.

The system prompt had no content assertions: blanking it left every
test green while every dimension agent would have launched with no
instructions. Its contract lines are now pinned the way the sibling
builtins pin theirs.

The stale `coverage.ts` comment ("`agentName` is `general-purpose` for
all of them") is updated — it is now the launched type, so it is not a
value to match on.

Web Shell has per-type display labels for every other builtin; adds the
en/zh pair so review agents do not render as a raw kebab-case id.

DESIGN.md now carries the per-turn record the totals decompose from, and
states the second-order share as 12,476 of 83,280 (15%) with its
arithmetic: the skills catalogue lives in the first user message, which
is re-sent every turn, so its 3,119-token saving is charged four times.
The figure was right; the prose put a per-turn number next to a
four-turn ratio and invited the reading that it was not.

* fix(review): make the review-agent prompt role-neutral and its tests bite

Second round on #9678.

The systemPrompt was written for a diff-reading dimension, but the same
type now serves every role the review launches, and it is
`systemInstruction` — it outranks the brief that arrives as a user turn.
Agent 7 reads no diff at all (`readsDiff: false`), `verify` rules on a
findings file, and `reverse-audit` exists to look outside what the first
pass covered, so a frame naming "your diff ranges" and bounding scope to
them contradicted three required roles. A blanket "silence is better
than noise" was worse: the finder briefs carry RECALL, whose whole point
is that a withheld half-believed candidate is unrecoverable, and the
verifier brief withholds RECALL deliberately — a confidence bar from
above breaks both halves. The prompt is now role-neutral and sends the
agent to its brief; the two restraint lines `general-purpose` carried
about a shared tree are restored. Its "absolute paths everywhere" note
also contradicted SKILL.md, which tells worktree-mode agents NOT to
prefix paths for `read_file`/`grep_search`; it now says what it means
about cwd and shell.

The `subagent_type` reminder sat inside the worktree-only `paramNote`,
so the three modes with no worktree were told nothing — and an omitted
type resolves to `general-purpose`, which is the entire cost this change
removes. It is now unconditional, with the non-worktree branch covered.

Three assertions had no teeth: `getBuiltinAgent` returns `null` and
`toBeDefined()` accepts it, `?.tools ?? []` satisfies every
`not.toContain` trivially, and `tools: ['*']` takes the
inherit-everything branch that the "declares a list" filter let through.
A renamed entry failed one test before and fails three now. The SKILL.md
guard was negative-only, so a reworded "Each is a general-purpose
subagent" passed; the set of `subagent_type` literals is now asserted
positively.

The Agent tool splices every type's description into its declaration in
every request of every session, so the description is one clause.

Documented rather than fixed: `agent`, `web_fetch` and MCP tools are
real losses against the inherited surface, not free savings; and a
user-authored `.qwen/agents/review-agent.md` shadows this builtin, which
also traps the file behind `deleteSubagent`'s builtin-name check.

Re-measured end to end on the rebuilt bundle: 55,669 delivered against
139,013 (-60.0%).

* fix(review): scope the cwd rule and correct the tool-search rationale

Third round on #9678. One of these is a regression the previous round
introduced.

The rewritten prompt said "Never `cd`", which is broader than the rule
it was porting: SKILL.md forbids `cd` into the pinned working directory,
not everywhere. The Step 4 verifier is sent to its own scratch tree and
told to work there, and that tree is a SIBLING of the review worktree
(`<worktree>-scratch-<label>`), so `run_shell_command(directory:)` fails
the workspace check and `cd` is the only remaining route. A blanket ban
left the verifier running probes in the shared worktree — the #9207
contamination the scratch tree exists to prevent — or demoting them. The
rule is scoped now, and a test pins the scoping in both directions.

The stated reason for excluding TOOL_SEARCH was wrong, and it was
load-bearing: it was also the argument for rejecting the deferral
alternative in DESIGN.md and in the PR description. A subagent does not
share the parent's registry — `rebuildToolRegistryOnOverride` builds one
per launch and rebinds `getToolRegistry` on the override config — so a
reveal cannot reach the orchestrator's declarations or its cache prefix.
The exclusion still stands on a closed list and on the schema costing
357 tokens/turn, more than two of the tools kept. The DESIGN.md
rejection now rests on the measurement and the blast radius, and records
the argument that did not hold.

Documented, not fixed, both pre-existing and both first surfaced by a
type with a restricted list: `coreToolScheduler`'s skill-activation
reminder gates on the registry rather than on the declared list, so it
is unconditionally true and announces a tool the agent does not have;
and `buildMcpServerInstructionsReminder` has no gate at all beside two
reminders that do. Each changes what every subagent receives, so each
belongs in its own change. The capability the second-order saving buys —
review parts can no longer invoke a project skill — is now named beside
the number rather than left implied.

Re-measured on the rebuilt bundle: 55,789 delivered against 139,013
(-59.9%).

* fix(review): state the launch type on every emission path

Fourth round on #9678, and one item is a gap the third round's own fix
left open.

`typeNote` was hoisted out of the worktree gate but still printed only
by `runRoster`. Step 4's verify shards and Step 5's audit rounds are
built by the other two emission paths, and those are both the most
numerous agents a high-effort review launches and the ones furthest from
SKILL.md's statement of the rule — an omitted `subagent_type` there
resolves to `general-purpose` at full cost, silently. It is now a module
constant carried by all three.

The single-block path carries it on stderr, not stdout. Its stdout IS
the block the orchestrator pastes verbatim, and the delivery check
compares that against the record, so appending to stdout made every
launch differ from its record — five existing tests caught it, which is
the guard working. stderr is the channel this command already uses for
operator-facing lines, and via the ...Safe writer, since #9213 pins that
a broken stderr must not refuse a build.

The SKILL.md paragraph is cut from 816 characters to 457. The skill's
own convention keeps narrative in DESIGN.md because SKILL.md is loaded
on every orchestrator turn — quoting four numbers already verbatim in
DESIGN.md was the opposite of this change's thesis. It also named the
tool set as "read, grep, glob, shell, write, edit", four labels matching
no registered name, while the next sentence asked the orchestrator to
judge what falls outside that set; it now names the registered tools and
a test pins the prose against the registry.

The `subagent_type` assertion is a set, not an ordered array. Pinning
count and order froze the document's shape, so restating the rule at
Steps 4 and 5 — strictly more correct — would have turned it red.

* test(review): give the review-agent pins teeth, and log the monitor loss

Fifth round on #9678.

Four assertions did not hold what they claimed. The tools array was
pinned with `toEqual`, so alphabetising a list whose order carries no
semantics turned the suite red while changing nothing; it is a set plus
a length now. The SKILL.md tool-name pin ran one way only — registry
names must appear in the body — so shrinking the registry left the skill
advertising a capability the agent no longer has, green; it is now set
equality against the sentence itself. The stderr assertion joined every
accumulated mock call in the file and passed whether or not its own
invocation emitted anything, because the enclosing beforeEach clears
only stdout. And the third emission path — the reverse-audit round
header — was asserted nowhere, so dropping its note shipped green while
every Step 5 auditor launched untyped. Each is mutation-verified against
the case that used to pass.

`run_in_background: false` is now pinned too. Dropping it defaulted
every review agent to a background launch, whose findings never return
inline — the review stalls in Step 4 with nothing to aggregate, and no
test was red.

MONITOR joins the deliberately-absent ledger. It is the one removal the
agent is actively pointed at: `shell.ts` answers a blocked foreground
sleep with "use the Monitor tool", and it is not in the subagent
exclusion set, so a `general-purpose` review agent had it. Recorded with
the measurement that qualifies it — across both arms of a real A/B
review, neither the guidance nor the tool ever fired.

* docs(review): record how to re-run the tool-surface measurement

Sixth round on #9678. The figures are load-bearing and quoted in five
places, but a reader had no way to re-derive them — only to re-check the
arithmetic against a table.

No script is committed, because none is needed: both halves of the
measurement run on commands this repository already ships. DESIGN.md now
carries the two-arm build, the per-turn capture, the run-level ledger
read-back, and the environment facts that are part of the result — the
51-tool arm is the product default (35 of them `computer_use__*`, on by
default), not a local quirk.

Two traps are named because both are easy to hit and silent. A second
run of the same PR is an incremental re-review, so an arm sharing a
working tree with the other reuses its findings and the comparison
measures nothing; and `review mock-provider` truncates its record at
8 KB, which is smaller than one tool block, so its log cannot be the
source for per-turn token counts.

Deliberately not a `###` heading: SKILL.test.ts requires every incident
heading under that section to carry a SKILL.md pointer, and a procedure
note is not an incident the orchestrator needs pointed at.

* fix(review): drop the stderr launch note, and stop asserting the brief is a file

Seventh round on #9678. Both items are regressions this PR introduced.

The launch note on the single-block path was moved to stderr last round
because stdout is the block the orchestrator pastes verbatim. stderr is
not a second channel: `ShellExecutionService` returns
`stdout + separator + stderr` as one string and `ShellToolInvocation`
hands that back, so the note arrived inside the very text the caller is
told to copy. It failed the same recorded-prompt equality as stdout
would, except invisibly — the five tests that catch the stdout version
see nothing — and removing it by hand is the edit the delivery gate
forbids, so those launches could enter drift/relaunch repair. The note
now has no channel on that path, and the code says why so it is not
re-added. The rule still reaches those launches: SKILL.md states it for
every `agent` call, and the two paths whose note sits OUTSIDE the ─────
blocks — the roster header and the audit-round header — still carry it.

The role-neutral prompt asserted that the assignment is a brief on disk.
Agent 8 is the reachable exception: `buildWholeDiffBlock` deliberately
writes no brief and SKILL.md appends its domain brief inline, so a
specialist launched that way was told from `systemInstruction` — which
outranks its own launch prompt — to read a file that does not exist. It
is optional and outside `requiredAgents`, so a generic diff walk would
have passed coverage in its place. The instruction is conditional now:
read the brief when the launch names one, otherwise the inline
assignment is the brief.

Both are mutation-verified against the shape that used to pass.

* docs(review): re-measure after the Agent 8 prompt fix

The conditional-brief wording is 27 tokens/turn longer than the sentence
it replaced, so the delivered figure moved: 55,789 to 55,897, and the
gap it is measured against from 83,224 to 83,116. Every citation in the
tree is realigned, including the per-turn table's system-prompt column.

Correctness bought the difference, and it is worth it — the previous
wording sent Agent 8 after a brief that does not exist. Recorded because
a figure quoted in five places is only useful while it matches what the
built bundle actually delivers, and this one is re-measured from that
bundle rather than adjusted on paper.

* docs(review): revert the test file too in the A/B recipe

The re-run recipe's baseline-arm revert list named the review sources but
not `agent-prompt.test.ts`, which imports `REVIEW_BUILTIN_SUBAGENT_TYPE`.
Reverting core removes that export, so `build:packages` fails with
TS2724 — and because the recipe chains with `&&`, the bundle step is
skipped and whatever `dist/` was there before is copied as the baseline
arm.

Reproduced rather than reasoned about: the recipe as written exits 1 on
TS2724, and adding the test file to the list exits 0. That failure is
also what happened when this measurement was first taken; the bundle was
re-run by hand afterwards, which is why the arm was still correct and
the broken recipe went unnoticed.

The note says the general rule, since the specific file will drift:
reverting the sources is not enough, anything that references them has
to go back too.

* test(review): pin the agent-type labels, and fix a comment the delta outran

Both from the round-3 sandboxed verification.

The web-shell `agentType.review-agent` keys were consumed at a live read
site and pinned by nothing. `localizeAgentTypeName` falls back to the raw
id, so a missing key is invisible — the badge renders kebab-case beside
siblings showing "Explore" and "Status Line Setup" and no test fails. The
badge is also newly visible for review agents: both surfaces elide the
type prefix only for the default type, so giving the review its own type
turned a never-rendered label into one shown on every row.

The test goes through `getTranslator`, the lookup the component uses,
because that is what makes the nastiest miss catchable: it falls back to
the English table, so a key added to `en` and forgotten in `zh-CN` still
returns something other than the raw id. Comparing the two locales'
output is what catches it — mutation-verified both ways (zh key removed:
1 red; both removed: 3 red).

The other item is a comment this PR's own previous round outran: the test
that now asserts the single-block path emits the note on NO channel still
opened by saying it emits on stderr, and its name was a beat behind too.
Behaviour and assertions were correct; the prose contradicted them, which
is exactly what would mislead the next reader about what the test pins.
2026-08-22 09:51:25 +00:00
易良
6e787aa431
i18n(cli): translate Unset in the settings dialog (#9714)
Add the 'Unset' settings-dialog label to all 9 CLI locale dictionaries and register it in MUST_TRANSLATE_KEYS so the normal i18n checks cover it.
2026-08-22 09:50:10 +00:00
ytahdn
6bbb273a86
perf(web-shell): optimize streaming transcript rendering (#9672)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
* perf(web-shell): optimize streaming transcript rendering

* test(web-shell): pin streaming fast paths

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
2026-08-22 06:12:54 +00:00
Shaojin Wen
4188486419
feat(review): give the convergence observation a machine-readable half (#9623)
* feat(review): give the convergence observation a machine-readable half

The diagnosis could tell a human why a loop was not settling and gave a
caller nothing to act on. This adds the two remaining pieces of that work
item: matched recommendations as a closed code set, and the round's own
report on whether its machinery is working.

**`recommendations: [{code, basis}]`** — measurement to advice, with no
constants and no decisions. Four codes are matched from facts this round
already holds: `root-cause-triage` (files that carried findings before and
carry new ones now), `batch-fixes` and `stem-surface` (a trend that is not
falling, the latter only where a floor rung is left to take), and
`land-and-defer` (a round that posts no Critical — a loop whose blockers are
all fixed can end by merging, and a merged pull request cannot diverge).
Every entry carries the deterministic fact it was matched from. The set is
DERIVED from the diagnosis rather than stored on it, and the paragraph's
prose is generated from the same derivation, so the codes a caller wires and
the sentences a human reads cannot describe different rounds. It rides the
composed result and the durable artifact.

The design's menu is larger, and the codes left unimplemented are each
absent for a stated reason rather than forgotten — `split` needs the
diff-topology test, `reset-drift`/`rescope` need `srcDelta`, `fix-pipeline`
needs marker content-hash dedup, `reduce-cadence` is two thresholds, and
`re-anchor` was matched to a cause this repository's measurements did not
bear out. The last one is now DISCLOSED instead: an anchor chain that has
stopped is stated as a fact and prescribes nothing.

**Mechanism health** — a pipeline that has stopped and one with nothing to
do are both silent, so the round says what it can see about itself. Two
checks: a posting floor the reporting reading resolved to critical while the
enforcement backstop failed open (the default configuration's standing gap,
invisible from either side alone — and deliberately not a count of what
posted, since the deterministic `[test]`/`[build]` carve-out is the
mechanism working), and two consecutive rounds withholding the incremental
anchor (every later round re-reads the whole diff until one closes cleanly).
Stated, never acted on. The anchor decision moves to one shared predicate so
the disclosure and the marker cannot describe different rounds.

Also clears the seven Suggestions deferred through rounds 5-7 of #9461: a
dead `floorKnown` parameter and its dead helper, two comments still
describing the fold that was narrowed, the drafted-id path missing the
length bound `idFor` applies (which let the two ends disagree about one
comment over a shortened list), an English cluster clause whose plural form
read the new-finding count as another round, a comment opening the wrong
validator block, and the merged-provenance wiring's missing end-to-end
assertion.

Ten mutations, each verified to turn a named test red.

* fix(review): the posture disclosure needs the manifestation it asserts

Round 1 found two blockers, both in the mechanism-health half.

The posture check held on EVERY default-config round from 6 on — the
reporting and enforcement readings differ only in folding an absent floor to
`auto`, so the gap itself is permanent there — while the sentence it renders
asserts that a Suggestion posted inline because of it. On a Criticals-only
round, or on an APPROVE, that claim was simply false: the clause accused the
posture of failing on rounds where it was not asked to do anything. It now
requires all three conjuncts, because the sentence asserts all three. Gating
on the posted count is safe precisely where the enforcement reading is
false: `floorEnforcedReroute` never ran, so no inline Suggestion can be its
deliberate deterministic carve-out — and when enforcement does engage, the
second conjunct is false and the carve-out can never trip it.

The APPROVE branch also spread the health block twice. Removed — and the
branch's invariant is now written down beside it: neither half can fire
there (the posture half needs a posted Suggestion, which makes the event
COMMENT; the anchor half needs a fail-closed scope, which caps the verdict
off this branch), verified by probe rather than argued. The spread is kept
for symmetry with the convergence block above it, which carries the same
invariant, so a later check that CAN fire here does not have to rediscover
the wiring.

The duplicate has no test, and deliberately so: the branch admits no shape
where the block is non-empty, so any test for it would assert a state the
code cannot reach. The manifestation gate is pinned three ways — the clause
renders once, a Criticals-only round is silent, and a nothing-to-report
round is silent while its anchor-chain disclosure still stands.

* fix(review): close round 2 on the machine-readable half

Two blockers.

The posture check still accused a round the posture was running correctly.
SKILL Step 6 excludes a `[build]`/`[test]`/`[probe]` finding by source at
ANY floor — it is pre-confirmed and stays inline whether or not the floor
engaged — so a fully compliant round that defers every deferrable Suggestion
and posts one such finding satisfied all three conjuncts. My previous
round's argument for the gate was true and beside the point: when the
code-side reroute has failed open, the MODEL-side posture is the layer in
charge, and it carries the same carve-out. The count now excludes
deterministic findings, read off the claim line through the same projection
`floorEnforcedReroute` uses.

The health block shared the convergence paragraph's trim rank while having
no copy outside the posted body — the exact false-record class the
convergence paragraph's own fix exists to close. Worse at the sharpest
corner: with no diagnosis firing, rank 0 held ONLY the health note, so the
trim notice named "the convergence observation" for a section that never
existed. The block gets its own rank with its own name (shed before the
paragraph, because its reader is the operator who has the terminal line),
and the note now rides `ComposeReviewResult`, the `HEALTH:` terminal line
and the durable artifact.

Also: the anchor-chain check states what it measures and names what it
cannot see (the scope is the only withholding leg visible from the body
composer; a plan with no fetched sha, an unreadable plan, and a model
identity drift also withhold, and are decided where the marker is built);
`recommendations[].code` is checked against the closed set instead of cast
into it; the absent-code list now covers the whole eleven-code menu; and the
comment claiming an absent floor reads as `auto` "throughout this module"
now says which reading folds and which does not.

Nine mutations, each verified to turn a named test red. Two of them needed
the fixtures fixed first: the health field and the codes are spread into
three separately-maintained result constructions, and the tests only reached
one of them.

* fix(review): close round 3 on the machine-readable half

The blocker: `land-and-defer` matched on a zero that was not a confirmed
zero. A round capped `cannot-tell-existing-critical` posts no Criticals
precisely BECAUSE existing ones could not be ruled on — the entries ride
their own channel, are never counted, and were never shown fixed — and
`findings-unverified-at-compose` is the same shape. The body would then say
"Unresolved, please confirm:" and "no Critical is open" at once, and the
artifact would tell a machine consumer to merge. The count is passed only
when the blocker state is established, and the module's own "absent is not
zero" rule withholds the code otherwise.

Four of the seven suggestions are corrections to things this branch itself
introduced, three of them last round:

- the health note's own rank made the convergence block's "shed before every
  other" comment false; it now says which one goes first and why;
- the accusation counted pathless Suggestions, which the floor structurally
  cannot defer — no deferral entry can be built without a path — so it
  accused the posture of failing to move something it has nowhere to move;
- the anchor-chain sentence attributed the withhold to the round's SCOPE
  while the predicate it reads also fires on a dimension gap and on verdict
  caps; it now says only that the round did not close cleanly, and the
  docstring names all three legs;
- and the comment misplacement I claimed to have cleared was reintroduced
  one line up, by an insert anchored on the statement below its own comment.

Also: the closed code vocabulary is declared once and the type derived from
it, rather than a union and a runtime set kept in step by hand; the `HEALTH:`
terminal line and the health validator's refusal path are pinned.

Six mutations, each verified to turn a named test red. One needed the
fixture fixed first — every test drove a Suggestion that had a path.

* fix(review): close round 4 — land-and-defer needs the scope established too

The blocker: last round's gate covered two of the three unestablished
shapes. An unproven scope — a chunk nobody read, an uncoverable chunk, an
idle agent, context unavailable — also posts zero Criticals while
prior-round blockers sit unread, and the non-repost inference then reads
them as fixed. Such a round carried "cannot show that any of the diff was
read" and "no Critical is open" in one body, and the artifact told a machine
consumer to merge over the unreviewed chunk. The diagnosis moves below
`scopeUnproven` so the gate can read it.

Also: the anchor-chain sentence claimed a clean close ENDS the full-diff
re-reads, which the suite's own passing tests contradict — the marker also
withholds on a missing fetched sha and on a model-identity drift, both of
which a cleanly-closed round can carry. It now says "until a round's marker
carries an anchor again", matching the docstring beside it.

And the duplicate comment I reported fixed last round was only half-fixed:
the file carried the sentence twice, and I deleted the copy above the health
block while leaving the one that opens the recommendations block. Removed.

Six mutations. Three needed the fixtures fixed first, all the same shape:
the `cannot-tell` leg was measured against a bare plan whose unproven scope
withheld the code anyway, and the round's own `land-and-defer` test likewise
never had an established scope. A test that cannot fail without the line it
names is not a test of that line.

* fix(review): one gate for land-and-defer, with every leg named once

Three rounds added one leg at a time to the same gate, and each addition
left the previous rationale describing a gate that no longer existed. Round
5 found three more legs missing. This replaces the stack with one condition
and one comment listing every leg, so the next one cannot be added in a
place the others do not mention.

The legs round 5 named:

- a whiffed dimension withholds the anchor but was not withheld here, so
  the artifact told a machine consumer to merge over lines nobody re-read.
  The gate now reads the marker's OWN `anchorFailsClosed` predicate rather
  than enumerating its legs — which also SUBSUMES the two blocker states
  the gate listed separately, since both are caps and neither is
  `unreviewed-dimension`. Those conjuncts are removed rather than left as
  dead code that reads like extra protection.
- a truncated work list makes the non-repost inference unsound: a shed
  Critical is neither re-posted nor ruled on. The same `complete` flag the
  freshness rule already reads.
- a pure-foreign list holds none of this account's entries, so its own open
  Criticals cannot be re-posted at all.

Also the three stale comments this branch left behind: the superseded
"until one closes cleanly" wording in the `PrevRound.anchored` docstring and
in a test comment (the rendered text and its pin say "until a round's marker
carries an anchor again", and the negative pin is widened to a regex that
catches both spellings); and the health computation's claim that `cappedBy`
is still being appended below it, which the previous round's move made
false — it now names the real constraint, `dimensionGapsAreDepthOnly`.

And the fixture whose own numbers proved the list incomplete (`fresh: 9`
over a one-entry work list) no longer blesses an inference conditioned on
completeness; it uses a shape the pipeline's writer can produce.

Six mutations, one per leg, each verified to turn a named test red. The new
arms are table-driven from the shape that DOES offer the ending, flipping
exactly one leg per arm — the fixture failure that let three of these legs
ship unpinned was arms that would have withheld the code anyway.

* test(review): pin the two land-and-defer gate legs the mutants survived

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): pin the draft projections directly, one ledger setup helper

Round 6's two actionable suggestions, both test-side:

- `deferrableSuggestionsInline` and `draftedFindingsOf` were pinned only
  through composeReview. Both are exported and unit-tested directly: the
  severity gate, the claim-line-only deterministic-tag window, the
  pathless exclusion, the LEDGER_MAX_ID bound, the first-keeps dedupe,
  and a parity test asserting the count equals the exact set
  `floorEnforcedReroute` moves when the floor engages — the divergence
  the suggestion names.
- The identical covered-plan + prev-ledger setup pasted at 12 sites
  collapses to one `coveredWithLedger()` beside `coveredPlan()`,
  deriving the side-file name from the same prNumber. A typo in a
  hand-coupled name failed nothing — the reader swallows ENOENT and the
  test silently measured round 1; broken at the one place now, 13 tests
  redden.

Five mutation probes (helper name, pathless gate, deterministic gate,
id bound, dedupe), each verified red and restored to green.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-22 05:11:28 +00:00
Shaojin Wen
7bc0d80998
fix(review): audit Aone targets in cleanup's bypass tripwire (#9633)
* fix(review): audit Aone targets in cleanup's bypass tripwire

Step 9's bypass audit already flags same-account writes on GitHub that
bypassed `qwen review submit`, but Aone targets had no tripwire at all —
cleanup audited them against GitHub (a hostless report hit github.com's
same-named repo; a recorded Aone host pointed gh at a host it has no
auth on). Route the audit by the fetch report's recorded host with the
registry's cwd-origin fall-through, list the MR's comments through the
a1 CLI (default + --resolved union — the default listing hides resolved
comments), and flag any comment the authenticated account posted — or
edited — inside the window that the submit receipt does not vouch for.
Submit now records a commentIds receipt axis (Aone's sanctioned write
posts comments, not a review) on success and on a partial post.

Closes #9617

* fix(review): preserve both receipt axes on the submit receipt rewrite

The submit receipt is keyed by PR number alone but carries an axis per
platform — review ids on GitHub, comment ids on Aone — and each writer
rebuilt the whole file from only its own axis. A submit on one platform
silently erased the ids the other platform's submit vouched for a
same-numbered target, and that platform's cleanup audit then flagged
submit's own sanctioned writes as bypasses. Merge the whole prior
receipt into the rewrite so both axes survive. Also flatten a1's
message-less JSON error object in the audit's skip note instead of
paging its opening brace, tag an unparseable `a1 auth whoami` answer
with the failing command, name the audit's third disclosed residual (an
edit of an unvouched pre-window comment is invisible once its
discussion is resolved), and pin the previously unwitnessed audit
contracts: the receipt vouch's edited-arm exclusion, the Aone
auditSince window boundary, the --resolved union's dedupe, the header
shape, and both footer platform nouns.

* fix(review): tag null whoami answers and disclose audit residuals (#9633)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-22 01:48:51 +00:00
Shaojin Wen
e2de7d2884
feat(web-shell): Bind GitHub PRs to sessions with sidebar badge and search (#9543)
* feat(web-shell): Bind GitHub PRs to sessions with sidebar badge and search

When a PR is created from the Web Shell Git dialog, bind its number and
URL to the current session. The daemon accepts the binding through the
session metadata routes (validated at the route, bridge, SDK, and sidecar
layers, with the URL restricted to http(s) since it is rendered as a link
target), keeps it in live memory, and persists it as a per-session sidecar
file so the binding survives daemon restarts and follows the session
through archive/unarchive/delete.

The sidebar renders a #N badge next to the session title (opening the PR
via the desktop-aware external-link opener, shows the PR in the details
tooltip, and the session search now also matches PR number, branch name,
and worktree slug — so with many concurrent sessions, the one that
produced a given PR is one search away.
EOF
)

* feat(web-shell): Support multiple PR bindings per session

A session can produce several PRs (stacked or follow-up work), and
keeping only the latest binding would defeat the sidebar's
search-by-PR-number flow for every earlier one. The binding is now a
bounded list (10, oldest dropped) ordered by binding time: re-binding
the same number refreshes it and moves it to latest, the badge shows
the newest number with a +N overflow, the tooltip lists every bound
PR, and search matches any of them. The write API stays single-binding
per call; reads, SSE events, and responses carry the full list, with
the sidecar as the complete history merged over the live entry's
daemon-lifetime bindings.

* feat(web-shell): Show PR badges in the session overview and picker dialogs

The mission-control overview panel and the shared session picker row
(resume / delete / release dialogs) now show the same PR badge as the
sidebar — latest number with a +N overflow, opening the PR via the
desktop-aware opener — and the resume dialog's search matches bound PR
numbers, branch names, and worktree slugs through the shared
sessionMatchesGitQuery helper.

* fix(web-shell): Match the overview PR badge color to the sidebar accent

The overview card badge used the panel's neutral --primary tint while
the sidebar and picker badges use the accent violet; one element should
read the same on every surface.

* fix(web-shell): Address review findings on PR bindings

Read/display correctness (verified by ytahdn and the R1 review):
- mergeLiveSessionSummary merged {..existing, ..live} wholesale, so a
  live entry's this-daemon-lifetime prs overwrote the sidecar-enriched
  full history after a restart; prs is now merged by PR number (live
  url wins, history kept), and the dead merge branch in
  enrichPrSidecars is gone.
- The pr-only session_metadata_updated event carried no displayName,
  which SDK folds treat as "cleared" — the title blanked on every PR
  bind. The producer now echoes the current name.
- GitDialog synced sessionIdRef from the prop on every render, so the
  fresh session id the dialog resolves for its own side queries was
  clobbered before the binding read it; the sync now runs only when
  the prop changes.

Robustness:
- upsertSessionPr's read-modify-write is serialized per sidecar path,
  closing the concurrent-bind drop race under runSharedMany.
- The REST routes persist the sidecar before mutating the bridge, so a
  failure on either side leaves the binding durable.
- The ACP dispatch only upserts when the call actually binds a PR (a
  displayName-only rename no longer rewrites createdAt/order).
- pr.url is capped at 2048 chars across all four validation layers.

Structure & a11y:
- The three badge copies (sidebar / overview / picker) are now one
  SessionPrBadge component: shared CSS, count-aware aria-label,
  non-http(s) entries filtered defensively, and tabIndex=-1 inside
  listbox options.
- The SDK's duplicated PR validator is a single session-pr module used
  by both DaemonClient and events.
- Delete/Release dialogs' search matches bound PR numbers like Resume.

Tests: list-level live+sidecar prs merge, sidecar-vs-bridge echo
authority, route tests made order-independent, bridge
atomicity/cap/catalog-revision/displayName-echo cases, concurrent
upsert serialization, SDK fold keeps the name, GitDialog bind-failure
degradation, url-cap rejections at every layer.

* fix(core): harden session pr sidecar persistence and moves (#9543)

* fix(serve): align session pr echoes with the persisted sidecar (#9543)

Address round-4 review findings:

- R4-1 (Critical): bridge entries are re-created without prs on daemon
  restart / close / archive-restore, so ACP and REST metadata updates
  replied and broadcast only this daemon lifetime's bindings, silently
  dropping persisted history. Hydrate the entry from the sidecar before
  the mutation (new optional bridge seedSessionPrs) and make the ACP
  handler reply with the authoritative persisted list like the REST
  routes, fixing both the response and the session_metadata_updated
  event on all three surfaces.
- R4-2 (Critical): the non-live metadata fallback persisted the rename
  before the PR sidecar while bumping the catalog revision only after
  both writes succeeded; a failed sidecar write stranded a durable,
  unannounced rename behind a total-failure response. Persist the
  sidecar first so a failed write leaves nothing durable behind.
- R4-3: map InvalidSessionMetadataError in toRpcError to the REST
  invalid_metadata contract instead of an opaque -32603 Internal error.
- R1-5: add the stderr audit record for pr binding mutations, mirroring
  the displayName branch (accepted in the round-2/3 thread).
- R2-4 (source part): make enrichPrSidecars' archiveState required so a
  future archived-listing call site cannot silently enrich from the
  active chats dir.
- R2-16: filter non-openable URL schemes in the session details tooltip
  exactly like SessionPrBadge.

* chore(desktop): regenerate bun.lock to match workspace versions

Main's desktop lockfile drifted: @craft-agent/electron and
@craft-agent/shared are 0.0.5 in the workspaces but 0.0.1 in the
lockfile, and the @qwen-code/live-host workspace entry is missing.
bun install --frozen-lockfile (the Live Host CI gate) fails on any
PR touching packages/sdk-typescript/src/daemon/types.ts because of
this. Regenerated with bun 1.3.x.

* fix(web-shell): address R2 review findings on PR bindings

- SessionPrBadge: narrow onKeyDown to Enter only so the badge no longer
  blocks roving-listbox navigation keys in picker dialogs (R2-15).
- SDK updateSessionMetadata: per-entry prs shape gate so a hostile or
  buggy daemon response cannot surface javascript: urls or malformed
  numbers downstream; valid entries survive (R1-17).
- Tests: bridge mirror atomicity (valid pr + invalid displayName),
  GitDialog stale-id retry binding, list-level merge dedupe by number,
  organized + archived listing paths keep PR sidecars, DaemonClient pr
  request/parse + gate, Delete/Release dialog PR-number search (R2-6,
  R2-13, R2-14, R1-15, R1-17, R2-20).

* fix(serve): address R5 review findings on PR bindings

Best-effort hydration (R5-1/R5-2/R5-3): the sidecar hydration read at
all three metadata-mutation sites (ACP dispatch, primary and workspace
REST routes) now absorbs non-ENOENT I/O errors as "no sidecar" instead
of failing the whole call — a squatted sidecar path no longer turns a
pr-less rename into a 500/-32603. This also makes the R4-2 fallback
ordering test reach the branch it names.

Validation hardening (R5-4): pr.url rejects control characters at all
four layers (bridge via hasControlCharacter, route, SDK guard, sidecar
reader) — the url is interpolated into the stderr audit line, so a
newline-bearing url could forge audit records.

Traversal parity (R5-5, R4-5): the ACP session/update_metadata handler
now gates on isValidSessionId before any sidecar I/O, and the primary
REST route's gate moved ahead of runtime resolution so traversal ids
get 400 invalid_session_id identically on single- and multi-workspace
daemons (previously 404 on multi-entry registries).

Tooltip (R5-6): PR rows key on index+number — a hand-edited sidecar
with duplicate numbers no longer risks cross-row reconciliation.

Tests: FakeBridge callLog pins seed-before-mutate order (R5-9);
cross-workspace pr sidecar lands in the owning workspace's chats dir
(R4-4); multi-workspace traversal test; metadata-filtered listing
keeps prs (R1-15); Resume dialog PR-number search (R2-20); dialog
fixtures annotated DaemonSessionSummary[] (R5-7/R5-8); control-char
rejection cases at bridge and sidecar layers.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-22 01:30:31 +00:00
易良
e44a16d8b5
fix(cli): keep slash menu selection stable during streaming (#9494) (#9508)
While a response streams, session stats / pending-item updates rebuild
commandContext. useCommandSuggestions listed commandContext in its effect
deps even though only the async argument-completion callback consumes it,
so every context rebuild re-ran the search and replaced the suggestions
array with an identical-content copy. useCommandCompletion resets the
active index to 0 whenever the suggestions array identity changes, which
snapped the user's menu selection back to the first item mid-navigation.

Read the context through a ref (same pattern as historyRef in
slashCommandProcessor) and drop it from the deps so only real inputs —
query, command list, recent commands — rebuild the suggestions.

Adds hook-level regression tests wiring real useCommandCompletion +
useSlashCompletion: selection survives context churn (red before the
fix), query/command-list changes still reset it, and argument completion
still observes the latest context.
2026-08-21 16:47:15 +00:00
Shaojin Wen
f1d05b79fc
feat(review): detect self-MR on Aone targets in presubmit (#9629)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run
* feat(review): detect self-MR on Aone targets in presubmit

The self-PR verdict downgrade existed only for GitHub targets: the Aone
read path skipped presubmit entirely, so a review of one's own MR
silently carried the weight of an independent review (#9616) — exactly
the wrong direction for the most common local Aone flow, re-reviewing
one's own CR before the next amend.

presubmit now routes by platform. On an Aone target it compares the
authenticated account (a1 auth whoami) against the MR author from one
mr view fetch — case-insensitive, fail-soft on a deleted author,
fail-closed on an unreadable MR — and emits the same report shape with
the unbacked slices neutral (CI classification and comment dedup have
no Aone backing yet). The same fetch backs head drift via sourceBranch,
and a malformed pr_number/owner_repo stays a usage error rather than a
metadata blip. SKILL.md runs presubmit on Aone targets instead of
skipping it, and the "self-PR detection has no Aone backing" caveat is
gone from the skill and the user docs.

* fix(review): unify Aone live-head reads and the presubmit whoami gate

The round-1 review of the Aone presubmit found four seams the new path
had hand-derived a second time; each is now stated once:

- The self-PR comparison (including the load-bearing `author !== ''`
  guard) existed as two inline copies in presubmit.ts; isSelfReview
  states it once for both platform paths so a future normalization rule
  cannot diverge one platform silently.
- "An Aone MR's live head is mr view's sourceBranch" was hand-derived
  in five places in aone.ts, two of them untrimmed: a padded server
  value then manufactured a phantom "PR head advanced during review"
  (and a submit-time refusal) against the trimmed reads, for an MR that
  never moved. aoneHeadSha states the fact once; all five sites route
  through it, repairing the two untrimmed copies.
- The "same report shape as GitHub" invariant was convention only; both
  presubmit result literals are now typed against one PresubmitReport
  interface, so a field added to the envelope is a compile error on the
  path that forgets it instead of a silent toBool(false) at the
  consumer.
- The Aone path spawned `a1 auth whoami` twice per run (plain gate +
  JSON account read). The gate now runs the JSON whoami once and
  returns the account: one spawn per run, and no account fetch remains
  after the MR fetch that could throw uncaught and orphan the graceful
  metaUnavailable report — the fail-closed path pays no a1 work after a
  thrown mr view.

The padded-head regression cells for getPrMeta/getFetchMeta fail on the
pre-round code; the empty-guard, single-spawn, and report-shape
witnesses each fail under mutation probes. 4143 review tests green.

* test(review): pin Aone presubmit auth-gate throw path (#9629)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-21 14:15:27 +00:00
Shaojin Wen
04886c4354
fix(review): make the incremental cache work for Aone AGit-Flow CRs (#9630)
* fix(review): make the incremental cache work for Aone AGit-Flow CRs

* docs(review): qualify the Aone no-ancestry claims in comments and docs

The D7 comments described the ancestry gate as unconditional and both
ancestry tests as failing for every AGit-Flow update; the head test
alone fails for every amend (the clamp fires only on amend-plus-rebase),
and the narrowing join never lets a drift byte reach the published
scope. Qualify the ledger.ts SHA_RE block, the resolveIncrementalAnchor
docstring, the clamp-skip and call-site comments, the test-block
comments, and the design/user docs accordingly.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-21 11:06:59 +00:00
Shaojin Wen
0dd518f950
feat(review): disclose that Aone posts join the discussion gate only (#9625)
* feat(review): disclose that Aone posts join the discussion gate only

Aone has a dedicated ai_comment merge gate for AI-posted review
comments. A controlled probe on a scratch CR (issue #9614) resolved
the design doc's open question Q4: `a1 repo mr comment create` does
NOT auto-set isAiComment for the posting identity (a general and an
inline probe both read back false, re-checked against an async
classifier), and a1 v0.1.90 exposes no flag to request it — so
qwen-posted comments sit in the generic discussion gate only, and
the ai_comment gate never tracks them. The same probe re-confirmed
Q3: still no native reject/request-changes on the a1 mr surface.

Until a1 ships a marking flag (feature request to the a1 CLI), the
write path discloses the gate split instead of silently implying
participation: the Aone REQUEST_CHANGES note names the posted
comments as unflagged and the discussion gate as the only mechanical
block, and SKILL.md / the user docs carry the same fact.
createMrComment documents the constraint and is named as the seam
where a future marking flag wires.

* test(review): pin the directional ai_comment gate claim in the Aone disclosure note

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(review): bind the Aone gate-disclosure pins to content, call, and count source

---------

Co-authored-by: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-21 08:17:23 +00:00
易良
e40263ee55
chore(deps): Clear high-severity CVE baseline and harden the security gate (#9584)
* chore(deps): Clear high-severity CVE baseline and harden the security gate

- Bump OpenTelemetry stack to 0.221.x (fixes @opentelemetry/core advisories)
- Bump @larksuiteoapi/node-sdk to ^1.73.0 and override axios to ^1.19.0
- Bump mobilewright to ^0.0.53 (drops vulnerable sharp 0.34.x)
- Bump markdown-it to ^15.0.0 (drops vulnerable linkify-it 5.x)
- Update undici/fast-uri/brace-expansion/ip-address within range
- Adapt telemetry code to OTel API changes (forceFlush, processor options)
- Make security-checks a hard gate now that the high baseline is clean

* chore(deps): Refresh mobile-mcp vendored lockfile to drop vulnerable sharp

* fix(telemetry): stub sdk-node 0.221 env auto-config helper packages

sdk-node 0.221 extracted its env-based auto-configuration into
@opentelemetry/configuration, otlp-exporter-base, and
otlp-grpc-exporter-base, which it now requires eagerly. The existing
esbuild stub only covered the exporter-* packages, so the OTLP protocol
chain (grpc-js, protobufjs, otlp-transformer) re-entered the sdk-impl
static closure and tripped the serve fast-path bundle guard.

Stub the three helper packages when imported by sdk-node only; our own
protocol modules keep resolving the real packages. qwen-code never
reaches these helpers at runtime (explicit exporters + env scrub).

* fix(telemetry): disable metrics fallback without reader

* fix(vscode): restore nested dependency notices

* fix(deps): declare bundled punycode so its notice survives regeneration

The CLI esbuild config aliases punycode to the userland package
(esbuild.config.js), so the shipped CLI bundle contains MIT-licensed
punycode@2.3.1. Its NOTICES.txt section was lost because the only
lockfile paths reaching punycode were dev-only; the notice walker
(rooted at vscode-ide-companion) never sees a production declaration.

Declare punycode as a direct production dependency of the CLI (the
bundle input) and of vscode-ide-companion (which packages the bundled
CLI into the VSIX and owns NOTICES.txt), then regenerate the lockfile
and notices so the MIT notice is restored.
2026-08-21 07:43:32 +00:00
Dragon
d0d68c0e8b
feat(cli): extend non-blocking slash commands to more builtins (#9495)
* feat(cli): extend non-blocking slash commands to more builtins

#8130 opted /about, /help, and /settings in to run immediately while
a response streams. Apply the same criteria to eleven more builtins so
local UI controls no longer wait for the active turn:

- UI-preference commands whose saved changes apply through the
  existing settings hooks: /theme, /editor, /vim, /voice, and
  /terminal-setup.
- Read-only status commands: /tools, /lsp, /tasks, /hooks, /docs,
  and /bug.

Commands that submit model turns, mutate conversation state, or read
state the active turn is writing remain serialized, as documented in
the non-blocking slash commands design doc. Each opt-in is pinned by a
unit test.

* docs(cli): scope extended-command claim to the eleven opted-in builtins
2026-08-21 07:24:49 +00:00
Dragon
e09399e0fb
feat(core): make list_directory opt-in (disabled by default) (#9424)
* feat(core): make list_directory opt-in (disabled by default)

glob covers directory listing in most cases, so list_directory is now disabled by default to keep the tool surface lean. Enable it with tools.listDirectory.enabled=true or by listing it in the coreTools allowlist (--core-tools / tools.core). The plan-mode reminder no longer steers the model toward the tool.

* fix(core): align list_directory opt-in gate with prompts and allowlist parsing

Round-1 review findings on the opt-in gate:

- Normalise coreTools entries with parseRule so specifier forms such as
  list_directory(/src) still re-enable the tool. isLsToolEnabled used
  resolveToolName, which does not strip specifiers, while
  PermissionManager admits the same entry via parseRule — so the
  allowlist accepted the tool and the registry never got it (R1-1).
- Drop list_directory from the plan-mode block error. That message is
  returned to the model as the tool result of every blocked call, so it
  kept steering the model at an unregistered tool even after the system
  prompt stopped doing so (R1-2).
- Remove the now-inert ToolNames.LS entries and the prompt text that
  advertised the tool to built-in agents. The gate also guards the
  forked-registry rebuild path, so these agents were silently stripped
  of a tool their own prompts told them to use (R1-3).
- Say why the tool is missing and how to enable it in the tool-not-found
  message, instead of offering unrelated Levenshtein suggestions (R1-6).
- Cover the alias and specifier forms in the registration tests (R1-4),
  and drop the stale examples from the sub-agents and Java SDK docs,
  where the listed entry can no longer register the tool (R1-5, R1-7).

* fix(core): address list_directory opt-in round-2 review findings

- Reword the skill-review agent task prompt so the inspection guidance
  only references read_file; the run's tool filter has no
  list_directory, glob, or shell, so the old `ls` instruction was
  unsatisfiable for the turn-budgeted background agent.
- Resolve tool-name aliases (ListFiles, ListFilesTool, ReadFolder) in
  the list_directory not-found message so aliased calls get the
  enablement explanation instead of a Levenshtein suggestion.
- Attribute a missing list_directory to the workspace tools toggle when
  the workspace disabledTools set blocks it, since the opt-in setting
  cannot lift that state.
- Drop the coreTools allowlist advice from the enablement message:
  setting tools.core to ["list_directory"] alone would exclude every
  other tool.
- Switch the two remaining sdk-java runTransportOptionsExample copies
  from list_directory to glob; setAllowedTools only adds auto-approve
  rules and never registers a tool.
- Extract the shared fake-server scaffolding in the list_directory
  integration tests into a local helper, keeping the load-bearing
  CLI-flag comment.
- Pin the skill-review agent tools array and assert the learn-skill
  prompt steers to read_file / glob, matching the sibling planners.

* fix(core): only claim list_directory is opt-in-disabled when it is unregistered

The not-found explanation resolved aliases (ListFiles, ReadFolder) before
checking whether the tool was actually absent. The registry is keyed by
canonical names while the lookup that reaches this path resolves legacy
migrations only, so an alias call missed even when list_directory was
enabled — and the message then told the user to switch on a setting that was
already on, hiding the generic path's "Did you mean list_directory"
self-correction.

Gate the branch on the canonical name being absent from the registry, and pin
the two alias combinations that were unpinned: an alias against a non-empty
workspace disabledTools set, and an alias against a registered tool.
2026-08-21 07:24:05 +00:00
Shaojin Wen
98854a38d4
feat(review): fold the one-hop import widening into fetch-pr --since (#9332)
* fix(review): gate the recovered incremental anchor on the model that certified it

Incremental scoping is a same-model contract: "clean up to this commit"
is one model's verdict. The cache path has always enforced it through
lastModelId, but the anchor recovered from the posted review's ledger
marker shipped bare, so a round run under a different model would scope
sha..HEAD past code the current model never reviewed — permanently,
since each clean round re-anchors past the last.

The marker now carries the certifying model beside the anchor, riding
and falling with it: withheld on fail-closed and truncated rounds, and
dropped by the parser when the sha beside it did not survive. The
recovered-ledger context section names the model and instructs the gate
(absent counts as a mismatch — markers predating the field), and the
skill's incremental check requires a model match on both the cache path
and the marker-recovery path before scoping to the interdiff. The
findings work list still carries across models — every entry is
re-asserted against the code — only the anchor does not.

* feat(review): rescope — deterministic incremental plans, widened one import hop

Incremental review existed only as prose: Step 1 said "compute
git diff <lastCommitSha>..HEAD and use it as the review scope" and left
the mechanics to improvisation. The improvisable route — re-run
plan-diff over a hand-captured interdiff — silently degrades the plan
(no worktreePath, no PR identity, no heaviness), dropping Agent 0, the
modeled-system lens and every invariant agent from the roster.

`qwen review rescope --plan <plan> --anchor <sha>` moves the scope
decision into code: it re-validates the anchor against the history,
captures the interdiff with the pinned flags, widens it by one import
hop — every still-clean source file that imports a changed file
re-enters the scope with its full-range hunks — and rewrites the plan
in place with the same builders fetch-pr used, identity fields riding
through and post-image line counts intact. The plan gains an
`incremental` block; chunk briefs annotate each file's class (changed
= review in full, interaction = review the seam only), and whole-diff
briefs carry the frame once. Failure is directional: any refusal
leaves the plan untouched, so the fallback is the full-range review,
never a skip; an empty interdiff exits 3 and maps to the same-SHA
outcomes.

The widening exists because "clean" was certified against the code as
it stood: a fix that moves a contract can break an unchanged caller,
and an interdiff-only scope never re-opens it. Dependents only, source
only, one hop; the scan is a documented heuristic whose misses keep
exactly the pre-widening floor.

* fix(review): harden rescope and the widening against review findings

Findings from the PR's own review rounds, each verified before fixing:

- Scoped files now carry FULL-RANGE hunks; the interdiff only chooses
  which files are in scope. Since-anchor hunks broke inline-comment
  anchoring: a fix round that restores lines the previous round changed
  produces hunks that exist nowhere in the PR's own diff, and one such
  anchor 422s the whole posted review, all-or-nothing.
- EXT_MAP maps .js to BOTH .ts and .tsx — under react-jsx a .tsx file
  emits .js, and 921 of 6,200 relative .js specifiers in this repo named
  .tsx targets no edge could reach. Root-escape guard is segment-exact
  (a '..config' directory is not an escape), and the documented dist/
  deep-import remap now actually strips the dist/ segment.
- rescope refuses an already-rescoped plan (a second pass derived
  candidates from the shrunk file list and repointed fullDiffPath at the
  file it was about to overwrite) and a plan with missing or malformed
  files[] (normalising to [] silently dropped every widening candidate).
  All git calls are pinned with -C to the plan's worktree: pathspecs
  resolve against git's cwd, and from a subdirectory an unmatched
  pathspec exits 0 with empty output instead of failing.
- incrementalScopeOf honours its degrade contract: interaction entries
  whose edges failed validation are dropped, and a block with no
  surviving scope renders no incremental frame at all.
- Whole-diff briefs name each file with its scope class (capped list);
  chunk briefs state that scope classes override the generic duties for
  interaction files; heavy INTERACTION files get no invariant agents —
  their full-range slice is exactly the code the previous round cleared.
- incremental.contextFiles (23 KB measured on a 300-file plan, with no
  reader) is now a count; fullDiffPath is named in the skill prose.
  SKILL.md states rescope runs from the main checkout, not the worktree.
- Test batch from the mutation findings: exit-code literals pinned,
  --out exercised, diffPathAbsolute asserted, one-hop limit gated,
  same-sha refusal byte-compared, heaviness preservation asserted,
  test-file dependents excluded, cross-package widening exercised,
  fileLineCount covered at the git layer.

* fix(review): round-2 findings — slice the fetched diff, cap and reconcile the frames

Round-2 review findings, each reproduced before fixing:

- The composite is now a BYTE-SLICE of the fetched full-range diff, not
  a pathspec-scoped re-capture: a scoped re-capture cannot see a rename
  source, un-pairs the rename, and renders a whole-file add whose hunks
  exist nowhere in the PR's own diff — the second entrance of the same
  422 anchor class the round-1 redesign closed. Slicing also keeps the
  subset invariant byte-exact. sliceDiffByLines moves to lib/diff-plan.
- deltaFiles is reconciled with the sections the composite actually
  holds (a file restored to its merge-base state names no phantom
  scope; its importers still widen), a files[] whose entries carry no
  usable path refuses like an empty one (zero-compared must never read
  as nothing-changed), and an unwritable --out exits 2 instead of
  throwing.
- The whole-diff frame carries the same scope-class-WINS reconciliation
  as chunk briefs (agents 1a/1b sweep duties re-opened round-1 findings
  over interaction hunks), scope lists cap edges per entry (8) as well
  as entries (30), anchors render inertly, empty-string edges degrade,
  a chunk with no classed files gets no frame, and the frame wording is
  flow-neutral (review's base, not PR base).
- Roster: interaction paths subtract deltaFiles (a path in both lists is
  live delta — widening wins), the field is declared on RosterPlan, and
  heavyFiles' doc is re-attached.
- import-graph: dist deep-imports resolve under BOTH emit layouts
  (dist/src/… and flat dist/…), and the header now states the honest
  wrong-edge cost of unparsed exports maps (one extra widened file,
  never a narrowed scope).
- Tests: rename-preserving slice, restored-file reconciliation, empty
  and zero-usable files[], out-of-worktree cwd run, unwritable --out,
  exact contextFileCount, head-distinct heaviness oracle, .cjs
  resolution, both dist layouts, list/edge caps, both-lists roster
  widening, no-frame-for-unclassed-chunks.

* fix(review): round-3 findings — follow the lineage, absolute full-diff path

Round-3 review findings on the rescope layer:

- R3-1 (Critical): a file renamed BEFORE the anchor and deleted in the
  fix round carries two names — the post-image name in the interdiff,
  the left-side name on the PR diff's deletion section — so the section
  holding its unreviewed hunks matched no scoped name and silently
  vanished (or exited 3 as 'nothing new'). An unmatched delta file is
  now dropped only when a cheap per-file probe proves it a genuine
  RESTORATION (identical blobs on both sides of the PR range); any
  other lineage break refuses to the full range, and the check runs
  before the empty-sections exit so the refusal wins.
- incremental.fullDiffPath is absolute: a cwd-relative path is
  meaningless to the later step the field exists for (R3-9), and the
  exit-3 contract in the header now names both of its causes (R3-8).
- The roster's interaction-path reader applies the same validation the
  brief renderer does — anchor present, every entry carrying a
  surviving edge — and a malformed deltaFiles disables the narrowing
  entirely rather than just its delta-wins subtraction: with no
  trustworthy delta list there is no way to tell a seam-only file from
  a live one, and every malformation here must widen (R3-2, R3-10).
- Tests: rename-then-delete refusal, restored-only exit 3, unwritable
  --out leaves the plan byte-identical, sliceDiffByLines gets a direct
  suite (parse → slice → parse round-trip, byte-exactness over invalid
  UTF-8 and lone CR, range ordering and clamping), and the resolver's
  literal-form candidate is pinned.

* fix(review): certify the ledger anchor with the runtime model identity

* fix(review): pin the posted marker's model wiring and tighten the anchor-gate spec

* fix(review): round-4 findings — chunk-scoped role briefs, resolver gaps

Round-4 review findings:

- R4-1 (Critical): a chunk-scoped ROLE brief (the reverse auditors, the
  one role accepting a chunk) received per-file scope classes only from
  the globally capped list, so on a wide round its own files could be
  elided past entry 30 — the sole reviewer of that territory left
  without their class and with no way to recover the tail. Its own
  chunk's files are now listed in full, and the chunk brief's seam
  bullet drops the display cap for the same reason (R4-2); the cap stays
  where it belongs, on the whole-diff frame.
- The resolver gains the `.jsx` emit row (a JSX source emits `.js` under
  the same convention as `.tsx`, R4-4) and normalises bare-package
  subpaths through the same POSIX rules relative specifiers already get,
  refusing escapes (R4-5).
- A plan file that parses to JSON `null` now refuses instead of throwing
  a TypeError past the catch (R4-7), `fileLineCount` is `-C`-pinned like
  every other git call in the module (R4-9), and a `deltaFiles` array of
  non-string junk disables the roster narrowing exactly as a missing
  list does (R4-11).
- Tests: chunk-scoped role brief listing, `.jsx` and subpath
  normalisation, junk-deltaFiles widening, JSON-null plan refusal.

R4-8 declined with rationale, recorded in the code: a file absent at
BOTH ends of the PR range is either a net-zero add-then-delete (safe to
drop) or a rename-before-anchor whose deletion hunks sit under its
pre-rename name (dropping loses them). This layer cannot tell them
apart, and dropping re-opens the round-3 Critical, so the refusal
stands.

* fix(review): shed the anchor pair first and pin the round-3 findings

The marker's byte-cap loop dropped a finding before the anchor pair;
`dropped` then withheld the pair in the same render, so a capped clean
round lost a ruling it was owed. Shed the pair first — the work list
survives and recovery degrades to the full diff. Plus the round's pins:
attribution-off withholding of the runtime-injected model, the submit
fixture's production filename encoding, the skill's same-model gate
clauses, and the differing-SHA gate in the user docs.

* fix(review): scope the identity-channel claims and pin the branch-1 gate

The boundary comments and DESIGN.md claimed the runtime identity channel
delivers what the mechanism cannot: a model-authored command prefixes its
env, and the override reaches the child (measured in this repo's bash -c
spawn shape), so "the model the session ACTUALLY runs, not the id the
state JSON typed" overstated the guarantee. Scope every PR-owned claim to
what the wiring delivers — the runtime id supersedes the typed one, and
the channel stays forgeable, same posture as the cache path. Plus the
revert-guard's missing pin: branch 1's `If SHAs differ **and** model
matches` clause was unpinned, so a partial revert dropping only it left
every suite green (measured); the pin makes that revert fail and does not
misfire on the PR state.

* fix(review): shed the dead anchor tie-break and pin the reprieve clauses

* fix(review): round-5/6 Criticals — readers for restored files, honest exits

Per the posture announced last round, this lands Criticals only.

- R6-10: a delta file the fix round RESTORED to its merge-base state fell
  between both reader classes — no PR-diff section, so no full review, and
  inside `delta`, so the widening skipped it as a candidate. Its imports of
  files that are still changing therefore had zero readers. The restoration
  probe now runs BEFORE the widening and splits the set: every changed file
  (restored included) still pulls its importers in, because a revert moves
  their seam too — round 1 cleared them against the pre-revert callee, and
  (importer@head x callee@base) is a pairing no round has seen — while the
  restored files themselves become candidates in a second pass keyed on the
  LIVE delta, since a restored file importing another restored file has no
  moving side to check.
- R5-14: nothing past the plan write may throw. "Only exit 0 rewrites the
  plan" needs its contrapositive to hold, and a dead stdout (`qwen … | head`,
  a daemon redirect) made the courtesy reporting raise EPIPE — exit 1 over an
  already-rewritten plan, sending the caller down the "full-range plan
  untouched" branch against an incremental one.
- R6-16: `fetchedSha`/`mergeBaseSha` were taken on type-check faith. Both
  ends of the PR range must be object ids: a clobbered plan naming a moving
  ref would resolve at call time, so the interdiff describes one tree and the
  worktree reads another while the exit-0 plan claims incremental scope.

Each of the three tests was mutation-checked: reverting the fix it pins
turns it red.

* fix(review): round-6/7 Criticals — whole tree entries, and an async-proof exit

Criticals only.

- `restored()` compared blob oids (`rev-parse <ref>:<path>` yields nothing
  else), so a fix round that reverts the content and KEEPS `chmod +x` — or
  swaps a file for a symlink with the same text — was misclassified as
  restored and dropped from scope. Its mode-only section is in the PR's own
  diff (parseDiff emits one, planChunks gives it a chunk), so the incremental
  path narrowed BELOW the full-range floor it is documented to hold and
  exited 3 "nothing new" over a change nobody reviewed. The probe now
  compares the whole tree entry, mode included, via a pathspec-pinned
  `ls-tree`.
- The round-5 EPIPE guard caught only the synchronous throw. A dead stdout
  also surfaces as an ASYNC 'error' event on the stream, which no try/catch
  around the write can intercept and which terminates the process with exit 1
  — over an already-rewritten plan, sending the orchestrator down the
  "full-range plan untouched" branch against an incremental one. A persistent
  no-op 'error' listener makes that shape inert; the test now pins both.

Both tests were mutation-checked: restoring the blob-only probe, or removing
the listeners, turns them red.

* fix(review): stamp the round's model at capture, qualify it by provider

Two ways the same-model gate could certify a range under a model that
did not review it.

1. Deferred post. compose/submit read QWEN_CODE_MODEL at POST time,
   which tracks the session's CURRENT model — review under A, /model to
   B, "post comments" and the marker said B. The next round under B
   then scoped sha..HEAD past code B never saw. fetch-pr now stamps
   reviewModelId into its report when the diff is captured, and compose
   withholds the sha/model pair outright when that stamp disagrees with
   the runtime posting it: the round cannot name who reviewed the range,
   so it certifies nobody and the next round reviews in full. The
   findings still post.

2. One model id, two providers. A bare id is unique only inside one
   provider configuration; two of them exposing 'qwen3-coder-plus' would
   pass each other's gate. Config now publishes
   QWEN_CODE_MODEL_IDENTITY — <model>@<8-hex of authType+baseUrl> —
   beside the bare id, and the review flow prefers it. A runtime that
   publishes neither yields '', which reads as a mismatch, not as
   agreement.

The identity slot is process-global while the model is per-session, so
shellContextEnv hands it down only while it still describes the model
resolved for THIS session; a daemon side-session gets the bare id rather
than another session's qualification, since a confidently wrong identity
passes a gate the coarse one would have failed.

Every new test mutation-checked.

* docs(review): correct the absent-stamp and model-cap notes

The reviewModelId doc claimed compose reads an absent stamp as
"unknown"; it reads it as today's behaviour, and the reason is worth
stating — the report is written at the start of a round and read at its
end, so a missing stamp means an upgrade landed between the two, and a
runtime that publishes no model id empties the other side of the
comparison anyway.

The ledger cap's note predates the provider qualifier, which adds nine
characters to every id it bounds.

* style(review): prettier the reapplied round-model helper

* fix(review): drop the duplicate `incremental` field the merge left behind

main's #9100 declared `incremental?: unknown` on agent-prompt's local
PlanReport, and this branch already had one for the rescoped plan; the
merge kept both, which is TS2300 and failed the build for every PR in
the stack. Kept the documented one.

Missed locally because vitest transpiles through esbuild, which drops
types without checking them — a duplicate interface member is invisible
to the test run and only `tsc --build` sees it.

* fix(review): rule the same-model gate in the CLI, key the identity per session

Four blockers from round 9, all in the identity plumbing this PR adds.

R9-1: the recovery path's gate could never fire. The marker's `model` is
the provider-qualified identity (`<model>@<digest>`), but SKILL.md told
the orchestrator to compare it against `{{model}}`, which
BundledSkillLoader substitutes with the BARE `config.getModel()` — two
identity spaces that are never equal, so every same-model continuation
round silently re-reviewed the full diff, which is the whole payoff this
PR exists for. Read loosely instead, a prefix match would have accepted
another provider's same-named model and re-opened the scope-skip the
digest closes.

The comparison now happens in the process holding both values:
`pr-context` renders the verdict — "the same-model contract HOLDS" or
"**Do NOT pass the reviewed-at sha as `--since`**", naming both
identities either way — and the skill obeys that sentence instead of
comparing strings. A section with no verdict is a mismatch. The cache
path keeps its bare-`{{model}}` gate: Step 8 writes `lastModelId` from
the same bare value, so that path is self-consistent.

R9-2: in daemon mode the identity leaked across sessions. The slot is
process-global and first-writer-wins, and withholding by OMITTING the
key is not withholding at all — every spawn site composes the child env
as `{...process.env, ...getShellContextEnvVars()}`, so the stale global
rode the spread and session B stamped its marker under A's identity.
Now registered per session beside the model (dropped together on
unregister) and written as `''` on a miss, the precedent the agent and
prompt ids in that file already set. The global slot stays the
single-session CLI's fallback, guarded so one that describes another
model is dropped rather than mis-qualifying this one.

R9-3 (×2): the two wiring tests never cleared QWEN_CODE_MODEL_IDENTITY,
which the boundary under test prefers — so an ambient value, which this
PR's own Config now publishes into every subprocess, overrode the model
they set. Running the suites inside a Qwen Code session is the
dogfooding path, so that was the normal case, not the exotic one.

Also folds the four inline `?? ` chains into lib/round-model.ts:
`roundModelIdFrom` and `certifierMatchesRound`, the latter pinning
whole-string equality and every unknown — absent certifier, unpublished
runtime, two blanks — as a mismatch.

Every new test mutation-checked.

* feat(review): fold the one-hop widening into `fetch-pr --since`, drop `rescope`

main's #9100 landed anchor validation and scoping inside `fetch-pr`,
which is where this work belongs — so the `rescope` subcommand it was
built as is gone (612 lines of command, 728 of test), and what was
unique to it now runs on the `--since` path.

Two changes to what an incremental round reviews.

The scoped diff is a SLICE of the PR's own diff, not a re-capture of
`since..head`. The delta decides WHICH files are in scope; their hunks
come from the full range. Every hunk an agent can anchor a comment on is
therefore byte-identical to one GitHub renders, and an inline-comment
422 takes the whole Create Review call with it. It also dissolves a
refusal: an "undo per feedback" commit reverts lines back to base
content, so a re-captured delta carries hunks the PR's diff does not
contain — `hunks-outside-pr-diff`, which cost the round its whole scope.
Sliced, that file is simply reviewed at the shape GitHub shows.

And the file set is widened by one import hop. A still-clean source file
that imports a changed one re-enters: round 1 cleared it against the
callee's OLD shape, and (importer@head × callee@head) is a pairing no
round has seen. This is only expressible under slicing — an importer is
unchanged by definition, so no delta capture can show it.

`incremental.scope` names each file's class (deltaFiles, interaction
with the edges that pulled each one in, contextFileCount,
restoredFileCount) and the superseded full range stays at
`incremental.fullDiffPath`. A file restored to its merge-base state owes
no review — mode-aware, so a content revert that keeps `chmod +x` is not
a restoration — but still pulls its importers in.

New refusal `lineage-unfollowable`: a delta file with no section of the
PR's own diff under that name (a rename before the anchor) cannot be
sliced, and refusing costs a full review where guessing loses hunks.
An unparseable delta is `containment-unverified`, never `upToDate` — the
empty file list is the parser's, not the tree's, and reading it as
'nothing changed' would stop the round over a failed capture.

The scope logic is a pure module with injected readers, so it is unit
-testable without a repository. Four new fetch-pr cases cover slicing,
widening, restoration and the nothing-new stop; every one
mutation-checked, and each of the four mutants (no slice, no widening,
no restoration probe, widen on the live delta) turns the suite red.

* fix(review): make the blanked identity fall back, and drop the anchor pair whole

Round 10 filed no Criticals; these are the deferred items that were
defects rather than coverage gaps.

The R9-2 blanking silently disabled the bare-id fallback. `??` falls back
on ABSENT, not on empty — and the identity slot is deliberately written
as '' when a session has none to publish, because an omitted key is not
withheld (the spawn-site env spread leaks the parent's stale one). So a
blanked slot meant 'this round has no identity at all' rather than 'no
qualification, use the bare id': the round certified nobody and every
round after it re-reviewed the full diff. Both comments claimed the
opposite. Blanking must cost the qualification, never the identity.

`stripAnchor` dropped a foreign ledger's `sha` and left its `model`
behind — an identity certifying a range that is gone, which every reader
would have to know to ignore. They are written together, withheld
together by compose-review, and serialized only as a pair; they are
dropped as one now.

SKILL.md's recovery path is reached from a cache-path WITHHOLD too, not
only from an absent or refused anchor: a cache holding another model's
anchor stops the round at the cache, and the marker it never looks at
may hold one this model certified.

Five new tests, each mutation-checked: the blank-slot fallback, the
pair-drop, buildMarkdown's identity wiring, the per-session identity
registry (write and mid-session re-key), and `certifierMatchesRound`'s
engage case — every other case there is a refusal, so `return false`
survived them all.

* fix(review): repair the build, the retry class, and two import-graph edges

R1-1 broke `npm run build --workspace=packages/cli` outright:
`mergeBaseSha` is `string | null` and reached `treeEntryUnchanged`
un-narrowed (TS2345), because the guard above tested only `fullBytes` /
`fullText` and the compiler cannot see that a non-null capture implies a
base. Naming the null base in the rejecting conjunct narrows it — and it
is the same conjunct R1-2 needs, so the two fixes are one edit.

I missed this locally twice, and the reason is worth recording: in a
fresh worktree `tsc` bails with TS6305 before checking anything, so the
`grep commands/review` I judged by came back empty and read as clean.
Building core in the worktree first reproduces it immediately.

R1-2: a base-fetch failure was demoted `containment-unverified`, which
this skill's own taxonomy files under "deterministic for the same sha
and must NOT be retried" — so a CI checkout with a flappy base fetch
would pay a full review every round from then on, under a reason that
also misnames the cause (the delta read fine). The three causes are now
split by what a re-run would repeat: `base-untrusted` for a failed
fetch, `capture-failed` for a base that existed and would not read,
`containment-unverified` only for a successful merge-base that found no
common ancestor. SKILL.md's reason list says so too, and the test that
conflated the first and third is split in two.

R1-4: `candidatesFor` tried every extension remap BEFORE the literal
specifier, and `resolveSpecifier` takes the first membership hit — so in
a mixed JS/TS directory where both siblings changed, `./util.js`
resolved to `util.ts`. That is not one extra widened file, the cost this
module budgets for a wrong edge; it DISPLACES the true one, so the seam
brief names a pairing that does not exist while caller × util.js is
named nowhere and retires unreviewed under a `scope.interaction` entry
claiming the caller was covered. Every existing test used a
single-element membership, so none could tell precedence apart.

R1-3: the package-subpath escape check was `startsWith('..')` — the
exact misclassification `repoJoin`'s comment eight lines above names and
avoids segment-exactly. `@q/core/..config/mod.js` is a legal directory,
and reading it as an escape drops the edge silently.

Four new tests, each mutation-checked.

* fix(review): rule the anchor verdict on the sha the side file actually holds

R11-3: the section's RULED-FOR-YOU verdict was rendered from the ledger
this run RECOVERED, while the sha Step 1 passes comes from the side
file — and `persistRecoveredLedger`'s never-lower-round guard
deliberately keeps a HIGHER-round file when the recovery walk comes back
short (a concurrent lane, a paginated fetch that returned less than it
should, a latest review deleted or edited).

In that state a HOLDS about the recovered sha is obeyed against a
different one, certified by whichever model ran THAT round — so the
round scopes past a range only that model reviewed, permanently, since
its own clean verdict re-anchors past it. Compose's drift gate cannot
catch it: the re-run re-stamps under the running model, so the stamp
agrees with the runtime and nothing looks wrong.

The verdict now rules on what the file HOLDS, read back off disk after
the persist decision rather than inferred from it — the guard's outcome
is exactly the thing a caller would get wrong by reasoning about it. A
divergence is a no-verdict state: both shas are named and the round
reviews the full range, because nothing available here can say who
reviewed the span between them. The findings still carry.

Two new tests, both mutation-checked: the renderer's divergence refusal
(and that agreement, and a file holding no anchor, still rule normally),
and `persistedAnchorSha` reading back what the guard actually kept —
the second is what fails when the read-back is stubbed out, which the
renderer test alone could not see.

* fix(review): move the last identity comparison out of prompt text

R12-1 and R12-2 are the sixth and seventh findings in one class — two
boundaries meaning different strings by the round's identity — so these
close the class rather than the two instances.

R12-1: the cache-path gate compared BARE ids on both sides. Step 8 writes
`lastModelId: "{{model}}"` and the gate compared it to `{{model}}`, both
the bare `config.getModel()`, so two provider configurations exposing one
model name passed each other's gate — the exact case the recovery path in
this PR rejects. Self-consistent is not sound; it was consistently wrong
across providers, and I deferred it last round as an asymmetry when it was
a hole.

The gate moves into `fetch-pr`, beside the one the anchor already goes
through: `--since-model` carries WHO certified the anchor, the skill
copies both fields verbatim, and `certifierMatchesRound` — the same
function the marker-recovery ruling uses — decides. A mismatch reports
`cross-model-anchor` and reviews the full range, refused before the
history is consulted at all.

That leaves ZERO identity comparisons in prompt text. Six rounds have each
closed one channel and the next round found another; the reason the class
kept regenerating is that a comparison written in prompt text cannot
share the CLI's notion of the string, and `{{model}}` is structurally the
wrong one — it interpolates the bare id where everything the CLI records
is provider-qualified. The SKILL guard now asserts the absence, not just
the presence: no `lastModelId equals`, no `model matches`/`model differs`.

R12-2: the drift gate disengaged whenever the post-time runtime channel
was blank, even with the plan's stamp proving the round STARTED under a
published identity — so `certifying` fell back to the model-written
`input.modelId`, the channel these docstrings retire. The recovery side
already rules an empty running identity a mismatch; the certifying side
does now too. An UNSTAMPED round still keeps its old behaviour, because
it cannot prove disagreement either.

Two new tests, both mutation-checked.

* fix(review): keep the merge-base probe's exit status, not just its answer

R2-1: `mergeBaseSha === null` conflated the definitive "these histories
share no ancestor" (git exit 1) with a probe that could not ANSWER — exit
128, or a kill, which is the 120s timeout a large long-lived PR under CI
load reaches. The probe was wired through `gitOpt`, which discards the
status, and `lib/git.ts`'s own `gitProbe` doc condemns exactly that
collapse.

The consequence is the retry class again: the round reported
`containment-unverified`, which the taxonomy files under
"deterministic for the same sha and must NOT be retried", so a transient
merge-base failure cost the PR its incremental scope permanently and
named a cause that had not happened.

`GitProbe.mergeBase` now returns `{sha, status}`, `resolveMergeBase`
reports `probeUnavailable`, and the reason keys on it. The flag is
STICKY across candidates: the tracking ref can fail to probe while the
local fallback answers a definitive no-ancestor, and a round that heard
one unanswerable probe has not established determinism.

`probeUnavailable` is required rather than optional on the result type,
so a future producer cannot omit it and have the absence read as
benign — the shape of the last three findings in this class.

Three new tests, all mutation-checked: dropping the status split, and
dropping it from the reason, each turn the suite red.

* refactor(review): retire what slicing made dead, and pin what the caps cut

Suggestions from round 1, all mutation-verified by the reviewer and
re-verified here. Two are defects the slicing change introduced.

R1-8: `diffBase` still carried the ANCHOR while the published bytes had
become sections of `merge-base..head`. Agent 7 welds it into `--base`
and recomputes its own diff, so the probe would run over hunks the round
never reviewed and miss the ones it did — the exact error the field was
added to prevent, arrived at from the other side. The producer stops
writing it on a sliced round; the consumer's fallback to `mergeBaseSha`
is the correct answer there, and it still honours the field on a plan an
older CLI wrote, where a delta-range publish made it true. The
seam-crossing test now asserts the published range instead of the
anchor.

R1-7: `fullDiffPath` was cwd-relative while every agent reads through
`read_file`, which rejects relative paths, from inside `worktreePath`
where `.qwen/tmp/…` resolves to nothing. Absolute now, and the docstring
says NOTHING READS IT rather than naming consumers — the same
over-claim #9191's R10-1 caught in the sibling field.

R1-5: `containmentRuling` and its ~200 lines of helpers had no
production caller left — containment is structural once the published
diff is a slice of the PR's own — while a comment still claimed it
"runs on every incremental capture" and `hunks-outside-pr-diff` sat in
the reason union and the SKILL enumeration with no emitter. All gone,
including the integration file that existed only to exercise it.

R1-6: `fileLineCount`'s `repoRoot?` was a dead switch no caller set,
documented for `rescope`, which no longer exists.

R1-10: the capped-lists doc block sat above `chunkScopeBullets`, the
function that is explicitly UNCAPPED, so hover read the cap rationale as
documentation of its own contradiction. Moved to `scopeFileLists`.

R1-11 through R1-15 are test gaps, each named with the mutant that
survived. The caps are now pinned by what they CUT (the `(+N more)`
arithmetic is independent of the `.slice()`, so both markers stayed
correct with the truncation deleted); the malformed-block fixtures reach
the field validators, and one carries a bad anchor with VALID lists —
the only shape the anchor guard alone can reject; the restoration probe
is steered per REF, which pins both "entries differ ⇒ not restored" and
the mode half (a `chmod +x` with unchanged bytes is not a restoration);
the plan⇔slice pairing is asserted where the slice is genuinely smaller;
and the uncapped chunk-scoped path has a fixture that reaches the cap.

Every one of those mutants was re-run here and turns the suite red.

* fix(review): reconcile the incremental docs with slicing, pin the killed probe

* fix(review): keep the scope ruling honest — probe status, rename lineage, two-flag re-run

The restoration probe kept its exit status (an unanswerable ls-tree is
retryable infrastructure, not a deterministic lineage refusal), a restored
rename target carries its deleted source into the lineage check, a lossy
capture fails the scope ruling closed, the side-file re-run passes both
--since flags, a resolved base sheds the probe taint, and the brief
renderer agrees with the roster on what a corrupt delta list means.

* fix(review): brief the seams that have no other surface first

R4-1: an interaction file that carries a section of the PR's diff is
named twice — in `scope.interaction` and, uncapped, in the chunk brief
of whichever chunk holds that section. One that carries NONE is named
once. Those are the restored files the second pass pulls in: their own
content is base content, so no chunk holds them, and the capped
whole-diff list is the only place their seam is briefed at all.

Insertion order appended them LAST, so on any round past
`SCOPE_LIST_CAP` they were the first elided into `(+N more)` — the seam
went unbriefed while `scope.interaction` still recorded it as covered.
Coverage claimed and not delivered, which is the failure direction this
module's header says it does not have.

The cap now bites the redundantly-named entries first. It still bites:
a round with more sectionless entries than the cap elides some, and that
is the honest degradation rather than the silent one.

The module is pure but for two injected readers — the property its
docstring claims to make the whole decision testable without a
repository — and nothing exercised it directly until now; every existing
case reached it through `fetch-pr`. This adds that file, with the
ordering as its first property. Mutation-checked: restoring insertion
order turns it red.

* fix(review): measure the decode, and ask the FULL range about a rename

R5-2: the lossy-decode guard scanned the decoded TEXT for U+FFFD, which
cannot tell a substitution from the code point itself. The code point is
ordinary content — this repository carries four literal ones in source —
so a delta touching any of them, even as context, demoted the round to
`containment-unverified`. That reason sits in the recovery contract's
"deterministic for the same sha and must NOT be retried" class, so the
affected PR paid a full review every round from then on, under a cause
that had not happened. Both documented causes of that reason are false
for this arm.

Measured on the DECODE now: re-encode and compare byte lengths. A
substitution replaces an invalid sequence with three bytes and changes
the length; a buffer that legitimately holds U+FFFD round-trips
unchanged. Only invalid bytes can collide two names onto one, which is
the hazard this guards — the character never could.

R6-1: the rename-source ride-along fired only when the TARGET was
restored, and that is the wrong question. Rename detection is a
similarity threshold and the two ranges compare different pairs of blobs,
so the delta can pair a rename the full range renders as a plain deletion
beside a plain addition. With a live target and that straddle, nothing
rode along, the lineage check passed on the new name alone, and the
source's deletion hunks — content no round had seen — dropped out of the
slice with the anchor advancing past them.

The rule is the direct one: does the FULL range carry a section under the
source's name? If it does, that section is unreviewed content the slice
would drop, so the source rides and the lineage check keeps it. If it does
not, both ranges paired the rename, the net hunks already sit under the
new-side section, and riding the source would demand a section that does
not exist and refuse the round.

Three new tests, each mutation-checked — including an END-TO-END U+FFFD
case, because the unit test of the helper alone left the arm that calls
it free to revert.

* fix(review): ask the decoder whether a capture is valid UTF-8

The byte-length round-trip missed every LENGTH-PRESERVING substitution,
which is the shape a truncated capture actually produces: Node emits one
U+FFFD per maximal ill-formed subpart, and a 3-byte subpart substitutes
to a 3-byte replacement character. `F0 9F 98` — a cut-off 4-byte
sequence — decoded to one U+FFFD of exactly the length it replaced, so
the guard passed it as clean and scope membership was then decided on
collided path strings: a live delta file conflated with a cleared
sibling, its sections dropped from the slice, the anchor advancing past
hunks no round had read.

Asked of the decoder now — `TextDecoder('utf-8', {fatal: true})` — which
is the only thing that knows. A literal U+FFFD in ordinary content still
decodes cleanly, which is the distinction the guard exists to draw.

Four of the reviewer's byte sequences are pinned directly; the
byte-length heuristic calls every one of them clean.

* fix(review): ride the section the full range paired a deletion under

The ride-along asked whether the full range carries a section under the
rename SOURCE's name. The two ranges can also pair the same deletion
with DIFFERENT targets: base has `a.ts = A`; the anchor round rewrites
`a.ts` to `A'` and adds `r.ts ~ A`; the fix round deletes `a.ts` and
adds `q.ts` as an exact copy of `A'`. `anchor..head` pairs `a.ts->q.ts`
(100% similarity, zero hunks); `merge-base..head` pairs `a.ts->r.ts`
and renders `q.ts` as a plain addition. Nothing names `a.ts` in the
full range, so nothing rode along, the lineage check passed on `q.ts`
alone, and the published slice retired the source's net hunks — which
sit under the section labelled `r.ts` — at the next re-anchor. Content
no round had seen, gone from every later delta by construction.

The rule now asks where the full range put the deletion. A section
under the source's name rides as before. Otherwise, when the full range
paired the source with a different target, that carrier section rides
instead; a rename target of the full range is absent at the base by
construction, so the restoration probe cannot misread it as restored
and drop it. Otherwise both ranges paired the delta's own rename, the
net hunks already sit under the new-side section, and riding anything
would refuse the round for nothing — the pinned control for that shape
still scopes by the new name alone.

One battery test built from the two-range rendering: delta pairs, full
does not. It fails at the parent commit (the slice publishes `q.ts`
alone) and passes here.

* fix(review): widen the restored-file hop in both directions

R9-1. A file the fix round reverted plays both parts, and only one was
wired. As a change it pulls its importers in; as an importer, its own
base-era calls now face whatever the PR still moves — and that second
direction is the one a revert makes load-bearing.

Round 1 changes `i.ts` (`foo(x)` → `foo(x, y)`) together with its caller
`r.ts` and clears both at the anchor. The fix round reverts only `r.ts`.
The delta is `{r.ts}`, restored, so `deltaLive` is EMPTY — and the
callee it strands was changed BEFORE the anchor and is unchanged since,
so it is not in `deltaFiles` at all. Two layers then stopped the round:
the second pass resolved `r.ts`'s import against that empty membership
and found no edge, and even with the edge `scoped` took only the
importer side, so the section that actually moves was never kept and
`kept.length === 0` ruled `nothing-new` anyway. `upToDate` does not
advance the anchor, so every re-run rules the same and
`r.ts@base × i.ts@head` — the base-era call against the new contract —
retires reviewed by no round.

The membership is now every file the PR still changes, and the edges'
targets are scoped with their importers. Restored×restored pairs stay
excluded for free: a restored file carries no section, so it is never a
candidate. `contextFileCount` follows the same move — "considered and
not scoped in" is no longer "not an interaction key", now that a seam
can scope a candidate as a target.

One regression test in the pure module; each of the three edits is
killed by it independently. It also trips one existing fixture, which
declared `a.ts` restored while serving a full range that carried a hunk
for it — a state git cannot produce, since a file identical at both ends
of the PR has no section there. That fixture now serves the honest
range, which makes its `not.toContain('a/a.ts')` structural rather than
load-bearing; the comment says so.

* fix(review): gate the widening's worktree reads on lstat before opening

The readWorktree closure fetch-pr hands to widenScope is fed paths the PR's
own file list determines — candidate files and every ancestor package.json
discoverWorkspacePackages walks up. readFileSync follows symlinks and opens
whatever sits there: a planted fifo blocks the synchronous read forever and
a device like /dev/zero grows the buffer until SIGKILL. Neither death mode
throws, so the catch that releases the worktree lease never runs and every
later review of that PR refuses or re-hangs identically.

lstat first and treat anything that is not a regular file as unreadable —
null already means that to the widening, so an irregular path contributes
no edge and the round keeps the unwidened floor. Same gate the pipeline
already applies to this hazard class in script-lint's firstLineOf and
run-ledger's ledgerOccupant.

Witnessed by probe before the fix: the verbatim closure against a modeled
worktree whose src/package.json is a fifo blocked the full timeout budget
(exit 124); gated, it returns in milliseconds. Regression test pins the
gate through runFetchPr — served content carrying a real edge is never
read when lstat says the path is not a regular file — and removing the
gate fails it.

* fix(review): contain the widening's reads, and stop skipping invariants

Two Criticals on the widening, one of them a hole in the lstat gate that
answered the last one.

**The gate defended only the FINAL path component.** `widenScope` hands
the reader paths derived from the diff, and an INTERMEDIATE component
can be a symlink the PR itself planted — ordinary git content that a
standard checkout materializes, needing no platform cooperation. The
path stays lexically inside the worktree while the kernel resolves it
outside. That is an arbitrary-file read AND a channel out, because what
the reader returns is content-derived and reaches `scope.interaction`
in the published report.

Containment is now by filesystem reality: `realpathSync(abs)` must sit
under `realpathSync(resolve(root))`. Same class and same defence as
`script-lint`'s `firstLineOf`, whose comment already names it —
"SYMLINKED ANCESTOR … lstatSync only spares the final component".

The reader moves out of the `fetch-pr` closure into
`lib/worktree-reader.ts` so it can be tested against a real filesystem,
where the kernel does the resolving; a mocked `fs` would have passed
against a fiction, which is how the lexically-inside form got through
the first gate. Six cases there, and `fetch-pr` keeps one mocked test
for the WIRING — that it reaches the worktree through this reader at
all.

**A heavy interaction file keeps its invariant agents.** The skip rested
on the premise that an interaction file's full-range slice is code the
previous round already cleared, which holds only while the merge base
holds still. Nothing enforces that: the anchor gate validates `--since`
against head history, and neither the round cache nor the posted ledger
carries a base identity, so a BACKWARD base move — the author retargets
the PR to an older base, an ordinary GitHub operation — is accepted.
`newBase..anchor` then carries hunks no round has read, they arrive
inside a heavy interaction file's full-range slice, and these three
agents are the only ones that would walk them; the chunk agent for the
same file is briefed for the seam alone.

So the skip is off until an anchor can prove base continuity — recording
the base beside `lastCommitSha` and refusing on a change is a ledger
schema change, and it belongs in its own PR. Removing it costs three
agents on a rare shape (heavy, unchanged since the anchor, importing
something that moved) and gives back the direction this design refuses
to lose in. `incrementalInteractionPaths` and the `incremental` field on
`RosterPlan` go with it.

Both fixes are mutation-checked: dropping the realpath containment turns
the reader's two escape cases red with the canary content, and turns the
`fetch-pr` wiring test red; restoring the interaction skip turns the
roster test red.

* test(review): unit-test the seam narrowToDelta now composes

Round feedback, one of three. `narrowToDelta` is a thin wrapper over
`selectNarrowing` + `assembleSections` since the reshape, and the two
halves are the surface the widening uses — it runs between them and asks
`assembleSections` for a set LARGER than `selection.touched`. Every
scenario in this file drives the wrapper, so a change correct for
`touched` and wrong for any wider set was invisible here.

Two cases, against captures real git produced: the selection reports
only the touched paths while carrying every section the full capture
does (the state the widening needs in order to consider anything), and
the emit answers for whatever subset it is handed — reproducing the
wrapper's own bytes for `touched`, adding the other section whole for a
wider set, and answering null for a set the capture carries nothing for.

Mutation-checked with a mutant shaped like the gap: gating the emit on
`selection.touched` as well as `paths` leaves the wrapper's behaviour
exactly right, and turns exactly one test — the new one — red.

Also rewords a comment in `agent-prompt.test.ts` that read as a
standing admission ("deleting the anchor guard left the suite green")
when it describes the state BEFORE the case beneath it was added.
Deleting `typeof raw.anchor !== 'string'` today is a one-test failure;
the comment now says so.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
2026-08-21 07:11:50 +00:00
Shaojin Wen
575e62ee46
fix(autofix): bind the sandbox image to its pulled digest (#9527)
* fix(autofix): bind the sandbox image to its pulled digest

The sandbox image was exported as a mutable tag. `docker run <tag>`
resolves against the local store without re-pulling, so a co-resident
process with daemon access can `docker tag` different content under the
same name between the resolve step and the consumer. Export the
`<repo>@sha256:...` RepoDigests entry that matches both the pulled
repository and the digest the pull itself reported: RepoDigests is shared
by every tag of the same content, so index 0 can move off the pulled repo
under a same-content retag, and retagged foreign content keeps its own
repo — only the pair binds the export to what the pull fetched.

Pin the daemon endpoint for both spawns. The docker CLI resolves its
endpoint from DOCKER_HOST, then --context, then DOCKER_CONTEXT, then
`currentContext` in the pool-shared config.json; clearing DOCKER_CONTEXT
falls through to that last one, so the context is named explicitly and
DOCKER_HOST is dropped from the child environment. An inspect answered by
someone else's daemon hands back any digest it likes.

Write the step files through a non-blocking, type-checked append.
$GITHUB_ENV and $GITHUB_OUTPUT live under the runner-writable temp tree,
where a planted FIFO turns a plain append into a block until the step
timeout.

Extracted from #9214, which is frozen; these were R11-1 and R11-2 there.
The inspect timeout is now injectable so the tests can pin it, and the
suite covers the endpoint pin on both spawns, the FIFO and directory
refusals, cross-chunk stdout accumulation, and the timeout itself. Each
new test was checked against a mutant of the code it pins.

Refs #9089, #9524.

* fix(autofix): bind gate image inputs to the resolver step output (#9527)

* fix(autofix): revert repo-hygiene binding outside PR footprint (#9527)

The deterministic gate rejected the previous commit because
repo-hygiene.yml is CI machinery this PR never touched; review
feedback alone cannot authorize changes there. Restore the file
byte-for-byte and scope the workflow contract test to the two
autofix workflows this PR binds. The repo-hygiene binding is real
and is deferred to the review-findings follow-up queue for a
maintainer-owned change.

* fix(autofix): harden sandbox image consumers per review round (#9527)

- R1-2: extract the duplicated spawn guard (endpoint pin, settle-once
  finish, SIGKILL timer, stdout capture, error/close wiring) into one
  spawnDockerCapture helper; pullImage and repoDigestOf share it.
- R2-1: contract test fails when a workflow detects zero sandbox
  consumers instead of passing vacuously.
- R2-2: success-path e2e test for the digest-bound export; verified it
  kills the exportImage(image) mutant.
- R2-3: pin the daemon endpoint (DOCKER_HOST: '', DOCKER_CONTEXT:
  default) on every sandbox-consuming step, closing the $GITHUB_ENV and
  pool-shared currentContext channels past the resolver; contract test
  enforces the pin.
- R2-4: gate the repair step on the resolver outcome so a failed
  resolver can never relaunch the agent unsandboxed.

Also updates the workflow source pin in scripts/tests to the shared
helper's literals (required by the R1-2 refactor).

* test(autofix): pin repair outcome gate, derive contract set (#9527)

- R3-1: the contract test now requires every always()-gated consumer to
  also gate on the resolver step outcome, pinning the R2-4 fail-closed
  clause; verified that deleting the guard from the repair step now
  fails the suite (the mutant shipped green before).
- R3-2: route both main() e2e tests through withDockerStub; the refusal
  test's untouched-file asserts move before the temp-dir cleanup — they
  previously ran after rmSync, so they passed no matter what the
  resolver wrote.
- R3-3: derive the contract test's protected workflow set from the tree
  instead of a hand-enumerated list, so a new resolver step cannot land
  untested; repo-hygiene.yml stays in an explicit, staleness-checked
  exception set until its deferred binding lands.

* fix(autofix): pin resolver binary, make image check digest-aware (#9527)

* test(autofix): share resolver e2e scaffold, tripwire stale exemptions (#9527)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-08-21 06:46:52 +00:00