Skip to content

feat: select work tools per headless run - #254

Merged
byapparov merged 5 commits into
mainfrom
feat/issue-246-tool-selection-20261021
Oct 21, 2026
Merged

byapparov merged 5 commits into
mainfrom
feat/issue-246-tool-selection-20261021

Conversation

@byapparov

@byapparov byapparov commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Refs #246.

Intent

Headless jobs currently declare the full work-tool catalog unless callers encode selection as permission denials. Add a persistent tool_allowlist in the existing configuration, with aictrl run --tools <exact,names> as a per-invocation override.

Expected Impact on Users

Workflow authors can define the tools each job needs in configuration: a review or explanation step can omit editing and delegation tools without changing permission policy. Omitted selection keeps the default catalog; an explicit empty list exposes no work tools. Selection only narrows availability and never grants denied permissions. This candidate remains unreleased; reference-token measurements from the earlier selector candidate are qualified below.

Configuration and overrides

Put the workflow default in the existing aictrl.json or aictrl.jsonc:

{
  "tool_allowlist": ["read", "glob", "grep"]
}

Run without repeating that list:

aictrl run "Review this repository"

Override it for one invocation when needed:

aictrl run --tools read "Explain README.md"

Precedence is explicit --tools → final merged config tool_allowlist → default catalog. Lists replace rather than merge; the override does not change the next invocation's configuration. [] or --tools '' selects no work tools. Existing AICTRL_CONFIG supports a job-specific configuration file; this adds a field to the existing system.

Behavior

  • Validate exact available work-tool names before inference. Filter actual model declarations, matching tool instructions and supported nested dispatch against the same invocation catalog.
  • Persist the resolved selection with its user invocation. Continuation, compaction, Task children and Batch retain the selection boundary and permission checks. Children may narrow an inherited ceiling.
  • Expose the checked, typed plugin context.selection facade. Nested dispatch results retain optional file/symbol/resource attachment metadata.
  • Keep harness protocol tools outside the work allowlist. With an active output schema, StructuredOutput is reserved for the harness protocol; a custom tool with that name remains selectable as work outside schema mode. Selection never grants protocol permission.
  • Preserve main's structured command arguments, raw ordinary prompts, required-option handling and selected-stdin EOF ordering. Regenerated SDK contracts include both command arguments and tool selection.

Boundaries

Explicit or locally configured selection with --attach fails before receiver work because this client cannot verify remote enforcement. Discovery failures retain structured terminal events. Successful remote selection is not implemented.

Shell/plugin host code remains trusted; selection and fixture fetch/spawn guards are not an OS sandbox. Caller attachments and command-template expansion remain input preparation. Real-model C1 evaluation and supported receiver compatibility remain open; issue #246 stays open. No provider billing/cache savings, model-quality, native/Windows or release clearance is claimed.

Diff size and review scope

At this head versus current main: 25 files, +6,828 / -383 lines.

Area Added Removed
Generated SDK types 3,294 229
Selector tests and fixtures 2,819 0
Implementation and SDK build wiring 627 149
CLI documentation 88 5

Generated output and tests account for89.5% of additions. The largest generated expansion is the canonical v2 Config declaration (3,267 lines), plus UserMessage: the SDK build bridge now aliases the legacy types to those canonical schemas. This is a public type-authority change requiring review, alongside the tool-selection fields. The generation inputs/build bridge govern the checked-in output.

session/prompt.ts was already1,711 lines on main; this PR adds162/removes30, leaving1,843 lines. Its structural decomposition and repeated selection views are being assessed separately; no broad refactor or behavior change is included by this description update.

Current verification

Current corrective commit: 46f98d5993ad56ec033506d5e66f5da85ccc6179, following normal main-integration merge 26c53b22120b997568d02e60659eb758a5fa1cf9 (parents prior correctionede414a and main4e56b02c).

  • The integration tree passed 179 selector/attach/guidance tests (four existing skips),230 command/template/stdin/user-message tests and19 cases against an actual local provider/22 requests. Workspace and joint compiler checks, eight attach controls and three owned process-cleanup controls passed. Canonical SDK generation was repeated with identical output.
  • The three follow-up files qualify schema-name documentation, expose plugin attachment types and strengthen the actual20 ms deadline canary. The changed public canary passes normally and correctly fails under an injected cleanup fault; independent KILL and owned-process absence are verified. Canonical plugin consumers, internal/public attachment assignments and direct CLI/SDK/plugin compiler checks pass. The other594 tracked entries retain the tested merge bytes.
  • Both bounded changes passed independent source review. No helper/runtime/SDK-schema changes accompany the three-file follow-up. Normal commit/push hook results: ordinary commit and push completed; all six pre-push tasks passed with zero cache hits, including canonical SDK generation and plugin build. All 597 tracked bytes and Git modes were verified unchanged after hooks.
  • Hosted CI39611443485 passed 2,257 tests / 10 skips / 0 failures, with 15,719 assertions across 156 files. Its actual checkout 380e9942 combines main 4e56b02c with this head; its tree exactly equals published 46f98d59, with all 597 tracked blob hashes and Git modes verified. Current-head CodeQL passed with zero results/errors/warnings. Six scheduled five-minute polls ended at 14:19:53 UTC without a current-head formal review. The review gate remains pending; this observation is not approval. Production logs show an automatic-review skip for this PR at its configured two-execution ceiling. The projected logs omit the head SHA, so they do not directly bind the skip to this commit. The ceiling is intentional policy; no counter reset, limit change or manual re-review was triggered. No temporary-base bridge is used.

Review dispositions

Original 19-finding response: two fixes, one tracked deferral and16 ignores; all 19 verdicts were written/read back. Seven false low-severity findings were automatically dismissed.

Fresh review 5600212158, submitted 21 October at 13:12:17 UTC onede414a, has 13 additional findings: three fixes, three manager-owned P3 follow-ups for 23 October and seven ignores. The three fixes above are source/QA accepted. Full 13-finding response is posted; all 13 exact verdicts were written and read back, with zero failures. Existing 19 verdicts remain unchanged.

Earlier scripted measurements and hosted verification

The earlier same-binary selector experiment used five default and five selected runs with four model calls/three actual reads in every positive short task. Median first-input reference tokens were 3,459→2,019, session input 15,150→9,390 (−38.02%), output 59→59 and total 15,209→9,449 (−37.87%). These are offline o200k_base estimates from the earlier measured candidate, not fresh measurements of46f98d, provider charges or task-quality evidence. Timing was diagnostic, with no speed claim. Reproduction, raw data and exact source linkage.

The earlier ede414a CI39601479686 passed 2,024 tests / 10 skips / 0 failures and CodeQL passed. Its captured checkout55eb6a80 combined older main6c3b5483 with ede414a, so it does not validate the current main integration. The initial fresh-runner ripgrep download failure remains in CI39535319219; CI now provisions real ripgrep while retaining the fixture network guard.

Local evidence distinguishes scripted provider/source execution from native release qualification. Workspace typecheck invoked SDK/plugin build dependencies during integration QA: tracked source and SDK bytes stayed exact, while ignored SDK output physical modes changed 0644→0664 under the host umask. Follow-up checks used direct no-emit compilers. An external adopter withholding waitpid can retain its own zombie; that historical case remains a bounded cleanup failure, not arbitrary orphan reaping.

@byapparov
byapparov changed the base branch from fix/issue-244-tool-guidance-20261021 to main October 21, 2026 02:50
@byapparov byapparov closed this Oct 21, 2026
@byapparov byapparov reopened this Oct 21, 2026
@byapparov
byapparov changed the base branch from main to fix/issue-244-tool-guidance-20261021 October 21, 2026 03:12
@byapparov byapparov self-assigned this Oct 21, 2026
@byapparov
byapparov marked this pull request as ready for review October 21, 2026 10:00
@byapparov byapparov added the P1 Should have - High label Oct 21, 2026
await fail(
"Work-tool selection is not supported with --attach: this client cannot verify server-side enforcement. Remove --tools and local tool_allowlist or run locally.",
"INVOCATION_INVALID_ARGUMENTS",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Attach preflight fetch failure crashes runs unguarded.

Suggested change
)
Wrap the readToolSelection preflight in try/catch and route failures through fail(...) with a clear message (or treat discovery errors as "selection unknown → fail closed"), and give the well-known fetch a timeout so offline hosts degrade gracefully instead of rejecting the await chain.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/cli/cmd/run.ts:1221-1224):

Problem: Attach preflight fetch failure crashes runs unguarded
Detail: Every `aictrl run --attach` now awaits Config.readToolSelection(process.cwd()) → resolve(false), which runs Auth.all(), filesystem walks, and an unconditional fetch() of each well-known auth URL, with no try/catch. A network failure of that fetch (or any malformed config making load() throw) rejects the preflight as an unstructured error even when the user configured no tool selection at all — previously attach worked without touching local config. README sanctions failing on "invalid local configuration", but an unreachable well-known host now also breaks attach runs that used to work, bypassing the structured fail(..., "INVOCATION_INVALID_ARGUMENTS") path.
Suggested fix: Wrap the readToolSelection preflight in try/catch and route failures through fail(...) with a clear message (or treat discovery errors as "selection unknown → fail closed"), and give the well-known fetch a timeout so offline hosts degrade gracefully instead of rejecting the await chain.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Every aictrl run --attach now awaits Config.readToolSelection(process.cwd()) → resolve(false), which runs Auth.all(), filesystem walks, and an unconditional fetch() of each well-known auth URL, with no try/catch. A network failure of that fetch (or any malformed config making load() throw) rejects the preflight as an unstructured error even when the user configured no tool selection at all — previously attach worked without touching local config. README sanctions failing on "invalid local configuration", but an unreachable well-known host now also breaks attach runs that used to work, bypassing the structured fail(..., "INVOCATION_INVALID_ARGUMENTS") path.

    invocation.phase("bootstrap")
    if (args.attach) {
      // This checkout has no receiver/capability contract that can enforce selection.
      const configured = await Config.readToolSelection(process.cwd())
      if (configured !== undefined) {


const token = setup ? await Control.token() : undefined
if (token) {
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Empty if(token){} dead block kept on rewritten line.

Suggested change
}
Delete the empty `if (token) {}` block and the token binding if nothing consumes it.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/config/config.ts:137-138):

Problem: Empty if(token){} dead block kept on rewritten line
Detail: The refactor rewrites the feeding statement to `const token = setup ? await Control.token() : undefined` but keeps the pre-existing empty `if (token) {}` branch directly below it. The block is dead in both modes now — in readonly mode token is always undefined, and in setup mode Control.token() runs purely to feed an empty branch. Since the PR already touches both lines, this is the natural moment to remove it.
Suggested fix: Delete the empty `if (token) {}` block and the token binding if nothing consumes it.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The refactor rewrites the feeding statement to const token = setup ? await Control.token() : undefined but keeps the pre-existing empty if (token) {} branch directly below it. The block is dead in both modes now — in readonly mode token is always undefined, and in setup mode Control.token() runs purely to feed an empty branch. Since the PR already touches both lines, this is the natural moment to remove it.

    const token = setup ? await Control.token() : undefined
    if (token) {
    }

@@ -1,3 +1,4 @@
import { ToolSelection } from "../tool/selection"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Relative imports drift from the @/ alias convention.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/session/compaction.ts:1):

Problem: Relative imports drift from the @/ alias convention
Detail: The PR adds relative imports for the new module in several files — compaction.ts:1 and message-v2.ts:1 ("../tool/selection"), prompt.ts:1-3 ("../config/config", "../tool/batch", "../tool/selection"), tool-catalog.ts (all-relative), and run.ts:2-3 ("../../tool/selection", "../../config/config") — while the surrounding/adjacent imports in those same files use the package's "@/" alias, and the same PR imports the identical module as "@/tool/selection" in session/llm.ts and "@/config/config" in cli/cmd/generate.ts. Same modules, two specifier styles in one PR; the alias works for these paths (llm.ts proves it), so this looks like drift, not cycle avoidance.
Suggested fix: Switch the new imports to "@/tool/selection", "@/config/config", "@/tool/batch", and "@/session/tool-catalog" in compaction.ts, message-v2.ts, prompt.ts, tool-catalog.ts, and run.ts to match the established alias convention.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The PR adds relative imports for the new module in several files — compaction.ts:1 and message-v2.ts:1 ("../tool/selection"), prompt.ts:1-3 ("../config/config", "../tool/batch", "../tool/selection"), tool-catalog.ts (all-relative), and run.ts:2-3 ("../../tool/selection", "../../config/config") — while the surrounding/adjacent imports in those same files use the package's "@/" alias, and the same PR imports the identical module as "@/tool/selection" in session/llm.ts and "@/config/config" in cli/cmd/generate.ts. Same modules, two specifier styles in one PR; the alias works for these paths (llm.ts proves it), so this looks like drift, not cycle avoidance.

import { ToolSelection } from "../tool/selection"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"

if (failed.toolCall.toolName === "StructuredOutput" && input.structured) {
input.structured.repairs.add(failed.toolCall.toolCallId)
const error = await input.structured.reject(failed.toolCall.input)
if (!tools.invalid || !ToolGuidance.isProtocol(tools.invalid)) return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 structured.reject runs even when repair returns null.

Suggested change
if (!tools.invalid || !ToolGuidance.isProtocol(tools.invalid)) return null
Evaluate the guard first and only await structured.reject(...) on the path that actually returns the repair to the "invalid" tool.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/session/llm.ts:188-189):

Problem: structured.reject runs even when repair returns null
Detail: The StructuredOutput repair path now awaits input.structured.reject(...) before the new protocol guard, so the rejection (events/retry accounting) is recorded even when the function then returns null because the native "invalid" tool is absent — e.g. a custom .aictrl/tool/invalid.ts shadows the builtin. Previously reject() only executed when a repair was actually returned, so one bad call now produces both a persisted rejection and an unrepaired tool-call error, skewing corrective-retry accounting.
Suggested fix: Evaluate the guard first and only await structured.reject(...) on the path that actually returns the repair to the "invalid" tool.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The StructuredOutput repair path now awaits input.structured.reject(...) before the new protocol guard, so the rejection (events/retry accounting) is recorded even when the function then returns null because the native "invalid" tool is absent — e.g. a custom .aictrl/tool/invalid.ts shadows the builtin. Previously reject() only executed when a repair was actually returned, so one bad call now produces both a persisted rejection and an unrepaired tool-call error, skewing corrective-retry accounting.

          input.structured.repairs.add(failed.toolCall.toolCallId)
          const error = await input.structured.reject(failed.toolCall.input)
          if (!tools.invalid || !ToolGuidance.isProtocol(tools.invalid)) return null
          return {

const selection: NonNullable<Tool.Context["selection"]> = {
names,
restricted: snapshot.allowlist !== null,
assert(name, batch = false) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Selection facade duplicated 3x with divergent messages.

Suggested change
assert(name, batch = false) {
Add a ToolSelection.facade(names, catalog, opts) factory in packages/cli/src/tool/selection.ts and use it from prompt.ts (both the subtask stub and resolveTools) and tool/registry.ts so assert messages and freezing stay identical.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/session/prompt.ts:975-982):

Problem: Selection facade duplicated 3x with divergent messages
Detail: Three hand-rolled copies of the context.selection facade now exist: the pending-subtask one in run() (with a stub execute that always throws), the resolveTools one (with batch checks), and the re-wrap in tool/registry.ts for plugin contexts. The two assert error strings have already diverged ("is excluded by this invocation's tool selection/permissions" vs "is unavailable or excluded by ..."), and each copy freezes/duplicates names differently. A shared factory would keep behavior and messages aligned as this surface evolves.
Suggested fix: Add a ToolSelection.facade(names, catalog, opts) factory in packages/cli/src/tool/selection.ts and use it from prompt.ts (both the subtask stub and resolveTools) and tool/registry.ts so assert messages and freezing stay identical.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Three hand-rolled copies of the context.selection facade now exist: the pending-subtask one in run() (with a stub execute that always throws), the resolveTools one (with batch checks), and the re-wrap in tool/registry.ts for plugin contexts. The two assert error strings have already diverged ("is excluded by this invocation's tool selection/permissions" vs "is unavailable or excluded by ..."), and each copy freezes/duplicates names differently. A shared factory would keep behavior and messages aligned as this surface evolves.

    const selection: NonNullable<Tool.Context["selection"]> = {
      names,
      restricted: snapshot.allowlist !== null,
      assert(name, batch = false) {
        if (!names.includes(name))
          throw new Error(`Tool '${name}' is unavailable or excluded by this invocation's tool selection/permissions`)

await Bun.write(path.join(directory, ".aictrl/tool/invalid.ts"), collision)
},
})
expect(result.requests.length).toBeGreaterThanOrEqual(1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unfalsifiable >=1 request-count assertions.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/test/cli/run-tool-selection.test.ts:1197-1198):

Problem: Unfalsifiable >=1 request-count assertions
Detail: identity-unknown-repair (and the same pattern around lines 1214, 1279, and 1291) asserts `result.requests.length` >= 1, which passes for any positive request count. A regression causing a runaway repair loop, repeated inference rounds, or duplicated sessions still passes; every other test in the file asserts exact counts. These should pin the expected round count.
Suggested fix: Replace with exact counts (e.g. expect(result.requests).toHaveLength(2) for parent + repair round) as done in identity-native-invalid.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

identity-unknown-repair (and the same pattern around lines 1214, 1279, and 1291) asserts result.requests.length >= 1, which passes for any positive request count. A regression causing a runaway repair loop, repeated inference rounds, or duplicated sessions still passes; every other test in the file asserts exact counts. These should pin the expected round count.

    expect(result.requests.length).toBeGreaterThanOrEqual(1)
    expect(result.markers["work-marker.json"]).toBeNull()

? undefined
: await Promise.resolve()
.then(() => ToolSelection.parse(requested))
.catch((error) => fail(error.message, "INVOCATION_INVALID_ARGUMENTS"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Parse error masks clearer --attach rejection.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/cli/cmd/run.ts:493-497):

Problem: Parse error masks clearer --attach rejection
Detail: ToolSelection.parse(requested) runs before the `args.attach && requested !== undefined` rejection, so `aictrl run --attach URL --tools "read ,bash"` reports the whitespace parse error instead of the more decisive "work-tool selection is not supported with --attach" message, which applies to any --tools value combined with --attach. Both fail before configuration discovery, so the documented ordering guarantee holds — only the reported message is suboptimal.
Suggested fix: Move the attach+requested check above the parse: a selector combined with --attach is invalid regardless of its syntax.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

ToolSelection.parse(requested) runs before the args.attach && requested !== undefined rejection, so aictrl run --attach URL --tools "read ,bash" reports the whitespace parse error instead of the more decisive "work-tool selection is not supported with --attach" message, which applies to any --tools value combined with --attach. Both fail before configuration discovery, so the documented ordering guarantee holds — only the reported message is suboptimal.

    if (args.attach && requested !== undefined) {
      await fail(
        "Work-tool selection is not supported with --attach: this client cannot verify server-side enforcement. Remove --tools or run locally.",
        "INVOCATION_INVALID_ARGUMENTS",
      )
    }


// Mirror only dynamic canonical environment reads. Module-load Flag constants
// must not acquire different precedence in read-only configuration discovery.
export function settings(environment?: Record<string, string>) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Mixed home/config path sources in discovery pass.

Suggested change
export function settings(environment?: Record<string, string>) {
Thread the overlay through a parameterized Global.Path (config + home) so every read in one resolve pass shares precedence, or document that settings()/home() overlay keys are limited to those the well-known loop can actually set.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/config/paths.ts:28-40):

Problem: Mixed home/config path sources in discovery pass
Detail: directories() mixes path sources: the first entry is module-load Global.Path.config (never overlay-aware) while the home walk start/stop uses the overlay-aware config.home, and loadGlobal() likewise reads Global.Path.config directly. A well-known overlay value for HOME/AICTRL_TEST_HOME (the mechanism this PR adds for AICTRL_CONFIG_DIR/AICTRL_CONFIG_CONTENT) would therefore diverge global-config reads from home-walk reads within the same resolve() pass. No current writer populates HOME in the overlay, so this is latent rather than live.
Suggested fix: Thread the overlay through a parameterized Global.Path (config + home) so every read in one resolve pass shares precedence, or document that settings()/home() overlay keys are limited to those the well-known loop can actually set.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

directories() mixes path sources: the first entry is module-load Global.Path.config (never overlay-aware) while the home walk start/stop uses the overlay-aware config.home, and loadGlobal() likewise reads Global.Path.config directly. A well-known overlay value for HOME/AICTRL_TEST_HOME (the mechanism this PR adds for AICTRL_CONFIG_DIR/AICTRL_CONFIG_CONTENT) would therefore diverge global-config reads from home-walk reads within the same resolve() pass. No current writer populates HOME in the overlay, so this is latent rather than live.

  export function settings(environment?: Record<string, string>) {
    const disabled = environment?.AICTRL_DISABLE_PROJECT_CONFIG
    return {
      disableProject:
        disabled === undefined ? Flag.AICTRL_DISABLE_PROJECT_CONFIG : ["true", "1"].includes(disabled.toLowerCase()),
      configDir: environment?.AICTRL_CONFIG_DIR ?? Flag.AICTRL_CONFIG_DIR,
      home: (environment?.AICTRL_TEST_HOME ?? process.env.AICTRL_TEST_HOME) || home(environment),

...(input.format?.type === "json_schema" ? ["StructuredOutput"] : []),
]
ToolSelection.validate(
input.toolAllowlist ?? (ceiling === undefined ? (preference ?? null) : null),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Config allowlist: strict at top, silent under child.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/session/prompt.ts:1278-1280):

Problem: Config allowlist: strict at top, silent under child
Detail: The same config tool_allowlist value is strictly validated (throws on unknown/unavailable names) when no ceiling is present, but silently intersected — possibly to nothing — under a child ceiling. Since config is global while MCP catalogs are per-project/per-model, a config naming a project-specific tool hard-fails every top-level prompt in other projects (or while that MCP server is down), while the identical value inherited by child Task sessions is quietly narrowed to empty. The code comment declares the split intended, but the dual semantics are surprising enough to deserve explicit documentation or a warning when an intersection empties.
Suggested fix: Document the split in the README's selection section, or log a warning when a non-empty inherited/config selection intersects to an empty effective set.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The same config tool_allowlist value is strictly validated (throws on unknown/unavailable names) when no ceiling is present, but silently intersected — possibly to nothing — under a child ceiling. Since config is global while MCP catalogs are per-project/per-model, a config naming a project-specific tool hard-fails every top-level prompt in other projects (or while that MCP server is down), while the identical value inherited by child Task sessions is quietly narrowed to empty. The code comment declares the split intended, but the dual semantics are surprising enough to deserve explicit documentation or a warning when an intersection empties.

      ToolSelection.validate(\n        input.toolAllowlist ?? (ceiling === undefined ? (preference ?? null) : null),\n        [...catalog.entries.keys()].filter((name) => !controls.includes(name)),\n        catalog.known,\n        controls,\n      )

controls,
)
}
if (selection.allowlist) Object.freeze(selection.allowlist)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Empty allowlist array escapes Object.freeze.

--- a/packages/cli/src/session/prompt.ts
+++ b/packages/cli/src/session/prompt.ts
@@ -1284,1 +1284,1 @@
-    if (selection.allowlist) Object.freeze(selection.allowlist)
+    if (selection.allowlist !== null) Object.freeze(selection.allowlist)
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/session/prompt.ts:1284-1285):

Problem: Empty allowlist array escapes Object.freeze
Detail: `if (selection.allowlist) Object.freeze(selection.allowlist)` skips the freeze when the allowlist is [] (falsy) — exactly the `--tools ''` case — and the following Object.freeze(selection) is shallow, so the persisted snapshot's empty allowlist stays mutable in memory. No mutation path exists today, but the guard contradicts the facade's immutable-names contract and is a one-token fix.
Suggested fix: Use `if (selection.allowlist !== null) Object.freeze(selection.allowlist)` so the empty-array selection is frozen like every other allowlist.

Suggested patch:
--- a/packages/cli/src/session/prompt.ts
+++ b/packages/cli/src/session/prompt.ts
@@ -1284,1 +1284,1 @@
-    if (selection.allowlist) Object.freeze(selection.allowlist)
+    if (selection.allowlist !== null) Object.freeze(selection.allowlist)


Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

if (selection.allowlist) Object.freeze(selection.allowlist) skips the freeze when the allowlist is [] (falsy) — exactly the --tools '' case — and the following Object.freeze(selection) is shallow, so the persisted snapshot's empty allowlist stays mutable in memory. No mutation path exists today, but the guard contradicts the facade's immutable-names contract and is a one-token fix.

    if (selection.allowlist) Object.freeze(selection.allowlist)
    Object.freeze(selection)

@aictrl-dev

aictrl-dev Bot commented Oct 21, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 14 · ⚪ 5 · 0/19 resolved

  • ⚪ packages/cli/src/cli/cmd/run.ts:493-497 — Parse error masks clearer --attach rejection
  • 🟡 packages/cli/src/cli/cmd/run.ts:1221-1224 — Attach preflight fetch failure crashes runs unguarded
  • 🟡 packages/cli/src/config/config.ts:137-138 — Empty if(token){} dead block kept on rewritten line
  • ⚪ packages/cli/src/config/paths.ts:28-40 — Mixed home/config path sources in discovery pass
  • 🟡 packages/cli/src/session/compaction.ts:1 — Relative imports drift from the @/ alias convention
  • ⚪ packages/cli/src/session/compaction.ts:264-265 — Tautological role check on pre-filtered user msg
  • 🟡 packages/cli/src/session/llm.ts:188-189 — structured.reject runs even when repair returns null
  • 🟡 packages/cli/src/session/prompt.ts:481-484 — New run-loop guards throw bare Error, not NamedError
  • 🟡 packages/cli/src/session/prompt.ts:485-488 — Restricted resume with pending subtask hard-fails run loop
  • 🟡 packages/cli/src/session/prompt.ts:975-982 — Selection facade duplicated 3x with divergent messages
  • 🟡 packages/cli/src/session/prompt.ts:1269-1270 — Config allowlist forces model fetch during noReply record
  • 🟡 packages/cli/src/session/prompt.ts:1277-1282 — Selection validation ignores permission-denied tools
  • ⚪ packages/cli/src/session/prompt.ts:1278-1280 — Config allowlist: strict at top, silent under child
  • ⚪ packages/cli/src/session/prompt.ts:1284-1285 — Empty allowlist array escapes Object.freeze
  • 🟡 packages/cli/test/cli/run-tool-selection-attach.test.ts:303-347 — Attach tests lack timeouts vs 10s child deadline
  • 🟡 packages/cli/test/cli/run-tool-selection.test.ts:39-42 — Install guard only blocks bun; other PMs escape
  • 🟡 packages/cli/test/cli/run-tool-selection.test.ts:319-321 — Deadline SIGKILL leaks fixture grandchildren
  • 🟡 packages/cli/test/cli/run-tool-selection.test.ts:716 — Vacuous work-marker assertion in omitted-task branch
  • 🟡 packages/cli/test/cli/run-tool-selection.test.ts:1197-1198 — Unfalsifiable >=1 request-count assertions
🤖 Fix all 19 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #254 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.ts:493-497 — Parse error masks clearer --attach rejection
   Detail: ToolSelection.parse(requested) runs before the `args.attach && requested !== undefined` rejection, so `aictrl run --attach URL --tools "read ,bash"` reports the whitespace parse error instead of the more decisive "work-tool selection is not supported with --attach" message, which applies to any --tools value combined with --attach. Both fail before configuration discovery, so the documented ordering guarantee holds — only the reported message is suboptimal.
   Suggested fix: Move the attach+requested check above the parse: a selector combined with --attach is invalid regardless of its syntax.
2. packages/cli/src/cli/cmd/run.ts:1221-1224 — Attach preflight fetch failure crashes runs unguarded
   Detail: Every `aictrl run --attach` now awaits Config.readToolSelection(process.cwd()) → resolve(false), which runs Auth.all(), filesystem walks, and an unconditional fetch() of each well-known auth URL, with no try/catch. A network failure of that fetch (or any malformed config making load() throw) rejects the preflight as an unstructured error even when the user configured no tool selection at all — previously attach worked without touching local config. README sanctions failing on "invalid local configuration", but an unreachable well-known host now also breaks attach runs that used to work, bypassing the structured fail(..., "INVOCATION_INVALID_ARGUMENTS") path.
   Suggested fix: Wrap the readToolSelection preflight in try/catch and route failures through fail(...) with a clear message (or treat discovery errors as "selection unknown → fail closed"), and give the well-known fetch a timeout so offline hosts degrade gracefully instead of rejecting the await chain.
3. packages/cli/src/config/config.ts:137-138 — Empty if(token){} dead block kept on rewritten line
   Detail: The refactor rewrites the feeding statement to `const token = setup ? await Control.token() : undefined` but keeps the pre-existing empty `if (token) {}` branch directly below it. The block is dead in both modes now — in readonly mode token is always undefined, and in setup mode Control.token() runs purely to feed an empty branch. Since the PR already touches both lines, this is the natural moment to remove it.
   Suggested fix: Delete the empty `if (token) {}` block and the token binding if nothing consumes it.
4. packages/cli/src/config/paths.ts:28-40 — Mixed home/config path sources in discovery pass
   Detail: directories() mixes path sources: the first entry is module-load Global.Path.config (never overlay-aware) while the home walk start/stop uses the overlay-aware config.home, and loadGlobal() likewise reads Global.Path.config directly. A well-known overlay value for HOME/AICTRL_TEST_HOME (the mechanism this PR adds for AICTRL_CONFIG_DIR/AICTRL_CONFIG_CONTENT) would therefore diverge global-config reads from home-walk reads within the same resolve() pass. No current writer populates HOME in the overlay, so this is latent rather than live.
   Suggested fix: Thread the overlay through a parameterized Global.Path (config + home) so every read in one resolve pass shares precedence, or document that settings()/home() overlay keys are limited to those the well-known loop can actually set.
5. packages/cli/src/session/compaction.ts:1 — Relative imports drift from the @/ alias convention
   Detail: The PR adds relative imports for the new module in several files — compaction.ts:1 and message-v2.ts:1 ("../tool/selection"), prompt.ts:1-3 ("../config/config", "../tool/batch", "../tool/selection"), tool-catalog.ts (all-relative), and run.ts:2-3 ("../../tool/selection", "../../config/config") — while the surrounding/adjacent imports in those same files use the package's "@/" alias, and the same PR imports the identical module as "@/tool/selection" in session/llm.ts and "@/config/config" in cli/cmd/generate.ts. Same modules, two specifier styles in one PR; the alias works for these paths (llm.ts proves it), so this looks like drift, not cycle avoidance.
   Suggested fix: Switch the new imports to "@/tool/selection", "@/config/config", "@/tool/batch", and "@/session/tool-catalog" in compaction.ts, message-v2.ts, prompt.ts, tool-catalog.ts, and run.ts to match the established alias convention.
6. packages/cli/src/session/compaction.ts:264-265 — Tautological role check on pre-filtered user msg
   Detail: `previous` is produced by `.findLast((message) => message.info.role === "user")?.info`, so the guard `previous?.role === "user"` is always true whenever previous is defined — the conditional is redundant and slightly obscures the intent (inherit the previous user message's selection).
   Suggested fix: Use `input.toolSelection ?? previous?.toolSelection`.
7. packages/cli/src/session/llm.ts:188-189 — structured.reject runs even when repair returns null
   Detail: The StructuredOutput repair path now awaits input.structured.reject(...) before the new protocol guard, so the rejection (events/retry accounting) is recorded even when the function then returns null because the native "invalid" tool is absent — e.g. a custom .aictrl/tool/invalid.ts shadows the builtin. Previously reject() only executed when a repair was actually returned, so one bad call now produces both a persisted rejection and an unrepaired tool-call error, skewing corrective-retry accounting.
   Suggested fix: Evaluate the guard first and only await structured.reject(...) on the path that actually returns the repair to the "invalid" tool.
8. packages/cli/src/session/prompt.ts:481-484 — New run-loop guards throw bare Error, not NamedError
   Detail: The new run-loop guards throw plain Error ("StructuredOutput is denied..." here, "Tool 'task' is unavailable..." and "Missing native Task entry..." below), while sibling failures in the same loop follow the NamedError + Session.Event.Error pattern (e.g. Provider.ModelNotFoundError handling just above, Bus.publish(Session.Event.Error)). Headless consumers that classify failures via run.errors.ts / error events get an unclassified rejection for the whole invocation instead. This also changes schema-mode behavior: a StructuredOutput denied by permissions used to be silently stripped with the run degrading through the corrective-retry path; it now hard-fails on the first turn.
   Suggested fix: Introduce a NamedError subclass (e.g. ToolSelectionError) for these guards and publish/propagate it like ModelNotFoundError so run.errors.ts and JSON consumers can classify the failure; keep the remediation text.
9. packages/cli/src/session/prompt.ts:485-488 — Restricted resume with pending subtask hard-fails run loop
   Detail: run() throws whenever a pending subtask exists and the restored allowlist is non-null without "task" (or when a custom tool shadows the native task entry). Pending subtask parts persist across invocations, so resuming a session that was interrupted mid-subtask with a narrowed selection (e.g. `aictrl run --continue --tools read`) aborts the invocation every turn with a generic Error instead of progressing as previous builds did; the part stays queued and the session is only resumable by dropping --tools entirely. Failing fast may be intended for new invocations, but the resume path gets a hard loop-level error rather than a per-part skip.
   Suggested fix: Skip or error the pending subtask part individually (record an error part / synthetic user note) instead of throwing out of run(), or auto-include the native task tool in the effective names when a pending subtask exists and the native entry is intact.
10. packages/cli/src/session/prompt.ts:975-982 — Selection facade duplicated 3x with divergent messages
   Detail: Three hand-rolled copies of the context.selection facade now exist: the pending-subtask one in run() (with a stub execute that always throws), the resolveTools one (with batch checks), and the re-wrap in tool/registry.ts for plugin contexts. The two assert error strings have already diverged ("is excluded by this invocation's tool selection/permissions" vs "is unavailable or excluded by ..."), and each copy freezes/duplicates names differently. A shared factory would keep behavior and messages aligned as this surface evolves.
   Suggested fix: Add a ToolSelection.facade(names, catalog, opts) factory in packages/cli/src/tool/selection.ts and use it from prompt.ts (both the subtask stub and resolveTools) and tool/registry.ts so assert messages and freezing stay identical.
11. packages/cli/src/session/prompt.ts:1269-1270 — Config allowlist forces model fetch during noReply record
   Detail: When any selection resolves — including one derived purely from the global config tool_allowlist, not just explicit --tools input — createUserMessage now awaits Provider.getModel() plus a full ToolCatalog.load() during message recording. noReply prompts that previously recorded without touching the provider (the patch comment only preserves that for null selections) now throw ModelNotFoundError/provider errors before the user message is persisted, so an environment with a config allowlist but an unresolvable/offline model can no longer record noReply messages it previously could.
   Suggested fix: Skip catalog validation for noReply prompts (validate on first real turn instead), or catch provider/model resolution failures during recording and persist the unvalidated snapshot, deferring the error to run().
12. packages/cli/src/session/prompt.ts:1277-1282 — Selection validation ignores permission-denied tools
   Detail: ToolSelection.validate() receives eligible = catalog entry names minus controls with no permission filtering, while the effective names computed later via ToolCatalog.names() also drop permission-disabled tools. A selection whose every name is permission-denied (e.g. --tools bash with bash denied) passes validation, persists its allowlist, then silently runs with zero work tools — the model just chats with no error naming the permission conflict, which is exactly the confusing case this validation was meant to prevent.
   Suggested fix: Pass the permission-filtered set into validate (apply PermissionNext.disabled plus legacy tools:false toggles, same as ToolCatalog.names), or warn/fail when the resolved effective work-tool set is empty while the requested allowlist was not.
13. packages/cli/src/session/prompt.ts:1278-1280 — Config allowlist: strict at top, silent under child
   Detail: The same config tool_allowlist value is strictly validated (throws on unknown/unavailable names) when no ceiling is present, but silently intersected — possibly to nothing — under a child ceiling. Since config is global while MCP catalogs are per-project/per-model, a config naming a project-specific tool hard-fails every top-level prompt in other projects (or while that MCP server is down), while the identical value inherited by child Task sessions is quietly narrowed to empty. The code comment declares the split intended, but the dual semantics are surprising enough to deserve explicit documentation or a warning when an intersection empties.
   Suggested fix: Document the split in the README's selection section, or log a warning when a non-empty inherited/config selection intersects to an empty effective set.
14. packages/cli/src/session/prompt.ts:1284-1285 — Empty allowlist array escapes Object.freeze
   Detail: `if (selection.allowlist) Object.freeze(selection.allowlist)` skips the freeze when the allowlist is [] (falsy) — exactly the `--tools ''` case — and the following Object.freeze(selection) is shallow, so the persisted snapshot's empty allowlist stays mutable in memory. No mutation path exists today, but the guard contradicts the facade's immutable-names contract and is a one-token fix.
   Suggested fix: Use `if (selection.allowlist !== null) Object.freeze(selection.allowlist)` so the empty-array selection is frozen like every other allowlist.
15. packages/cli/test/cli/run-tool-selection-attach.test.ts:303-347 — Attach tests lack timeouts vs 10s child deadline
   Detail: No test inside this describe passes a timeout, so bun:test's ~5s default can fire before run()'s own 10s child deadline on a slow child — and the legacy-seeding tests additionally run two git spawnSync calls plus a seed bun spawn before the child starts. On timeout the abandoned run() promise never reaches its finally, leaking the Bun.serve port, the child process, and the tmpdir, and the failure message is a bare "TimedOut" instead of the fixture's diagnostic (which includes stdout/stderr). The sibling run-tool-selection.test.ts passes 20000 everywhere; only the final standalone canary test in this file does.
   Suggested fix: Pass an explicit timeout (e.g. 20000) to every test in this file, above run()'s deadlineMs, so the fixture's own deadline error — with stdout/stderr context — is what fails the test.
16. packages/cli/test/cli/run-tool-selection.test.ts:39-42 — Install guard only blocks bun; other PMs escape
   Detail: The fixture's spawn guard only throws when command[0] matches /bun$/ and command[1] is add/install. A dependency install triggered via npm/pnpm/yarn/npx/apt-get spawns unguarded, and those subprocesses use their own sockets, entirely bypassing the globalThis.fetch patch — so the "no installs, fixture network only" invariant has a real hole (the attach suite's guard, which blocks any cmd containing "install", is stricter). Anything that regressively installs deps via a non-bun package manager would pass this suite while performing real network installs in CI.
   Suggested fix: Mirror the attach guard: reject any spawned argv for known package managers (bun/npm/npx/pnpm/yarn/apt/apt-get) whose arguments include add/install/i, rather than only bun's exact argv[1].
17. packages/cli/test/cli/run-tool-selection.test.ts:319-321 — Deadline SIGKILL leaks fixture grandchildren
   Detail: On the 15s deadline path (and in finally), process_.kill("SIGKILL") kills only the direct child. The runtime fixture spawns grandchildren — restart-snapshot/restart-replay's bun process, export/import CLI processes, the MCP stdio child — which get reparented and can outlive the test, holding pipes and writing into the already-disposed tmpdir. With ~60 process-spawning tests, orphans compound CI resource pressure exactly when the runner is already slow enough to hit the deadline.
   Suggested fix: Spawn the child in its own process group (detached + kill(-pid), or Bun's equivalent) and kill the group in the timeout handler and finally so grandchildren die with the fixture.
18. packages/cli/test/cli/run-tool-selection.test.ts:716 — Vacuous work-marker assertion in omitted-task branch
   Detail: In the direct-task test loop, the customTask=false ("omitted") branch passes no setup, so .aictrl/tool/task.ts — the only writer of work-marker.json via the collision fixture — is never installed. expect(result.markers["work-marker.json"]).toBeNull() therefore passes on every possible path and adds no coverage to the omitted-Task rejection case; only the customTask=true branch can make it falsifiable.
   Suggested fix: Write the collision tool in both branches (setup: async (d) => Bun.write(path.join(d, ".aictrl/tool/task.ts"), collision)) so the marker check is falsifiable, or drop the marker assertion for the omitted branch.
19. packages/cli/test/cli/run-tool-selection.test.ts:1197-1198 — Unfalsifiable >=1 request-count assertions
   Detail: identity-unknown-repair (and the same pattern around lines 1214, 1279, and 1291) asserts `result.requests.length` >= 1, which passes for any positive request count. A regression causing a runaway repair loop, repeated inference rounds, or duplicated sessions still passes; every other test in the file asserts exact counts. These should pin the expected round count.
   Suggested fix: Replace with exact counts (e.g. expect(result.requests).toHaveLength(2) for parent + repair round) as done in identity-native-invalid.
📋 Out-of-diff findings (19)
Sev Location Finding
⚪ packages/cli/src/cli/cmd/run.ts:493-497 Parse error masks clearer --attach rejection
🟡 packages/cli/src/cli/cmd/run.ts:1221-1224 Attach preflight fetch failure crashes runs unguarded
🟡 packages/cli/src/config/config.ts:137-138 Empty if(token){} dead block kept on rewritten line
⚪ packages/cli/src/config/paths.ts:28-40 Mixed home/config path sources in discovery pass
🟡 packages/cli/src/session/compaction.ts:1 Relative imports drift from the @/ alias convention
⚪ packages/cli/src/session/compaction.ts:264-265 Tautological role check on pre-filtered user msg
🟡 packages/cli/src/session/llm.ts:188-189 structured.reject runs even when repair returns null
🟡 packages/cli/src/session/prompt.ts:481-484 New run-loop guards throw bare Error, not NamedError
🟡 packages/cli/src/session/prompt.ts:485-488 Restricted resume with pending subtask hard-fails run loop
🟡 packages/cli/src/session/prompt.ts:975-982 Selection facade duplicated 3x with divergent messages
🟡 packages/cli/src/session/prompt.ts:1269-1270 Config allowlist forces model fetch during noReply record
🟡 packages/cli/src/session/prompt.ts:1277-1282 Selection validation ignores permission-denied tools
⚪ packages/cli/src/session/prompt.ts:1278-1280 Config allowlist: strict at top, silent under child
⚪ packages/cli/src/session/prompt.ts:1284-1285 Empty allowlist array escapes Object.freeze
🟡 packages/cli/test/cli/run-tool-selection-attach.test.ts:303-347 Attach tests lack timeouts vs 10s child deadline
🟡 packages/cli/test/cli/run-tool-selection.test.ts:39-42 Install guard only blocks bun; other PMs escape
🟡 packages/cli/test/cli/run-tool-selection.test.ts:319-321 Deadline SIGKILL leaks fixture grandchildren
🟡 packages/cli/test/cli/run-tool-selection.test.ts:716 Vacuous work-marker assertion in omitted-task branch
🟡 packages/cli/test/cli/run-tool-selection.test.ts:1197-1198 Unfalsifiable >=1 request-count assertions

Reviewed 26 files · 0 inline · view all 19 findings ↗ · AI-generated — verify findings before acting · AI guidance and feedback


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov
byapparov changed the base branch from fix/issue-244-tool-guidance-20261021 to main October 21, 2026 12:39
@byapparov

Copy link
Copy Markdown
Member Author

Review response — PR #254

Addressed the 19-finding review with two bounded corrections in ede414a0d5b66037225583e6990e3dc9d41edad9. Totals: 2 FIX, 1 DEFER, 16 IGNORE; 12 TRUE, 7 FALSE. The configuration-first selector and permission-ceiling contract are unchanged.

Issues addressed

  • Attach bootstrap now uses the existing invocation guard for configuration discovery. Invalid local/schema configuration, owned HTTP503 and refused loopback connections preserve exactly one ordered invocation start/error/complete sequence with bootstrap classification. Strict selector syntax still fails before discovery, receiver calls or local setup.
  • Selector fixtures now own a detached POSIX process group and bound collection when descendants retain pipes. Cleanup independently attempts TERM/KILL, awaits the direct child, settles readers and stops the listener. Injected TERM failure still proceeds to KILL while preserving the original deadline and cleanup error.
  • Reused the accepted PR252 session-fixture correction byte-for-byte; no duplicate implementation.

Validation: 179 passed, 4 existing skips, 0 failed, 1,019 assertions; CLI typecheck passed; eight attach controls passed. Actual ready-barrier deadline, early-exit and TERM-fault controls prove owned child/grandchild absence before manual intervention. Baseline failures and a hostile external-adopter negative control are retained. An external adopter withholding waitpid can retain its own zombie; the helper reports bounded cleanup failure, and does not claim arbitrary orphan reaping or Windows coverage. Compatible receiver and real-model evaluation gates remain open.

Full verdict table

Native finding Severity Location Finding Verdict Action Reason
0e7500cd94634b3e NIT packages/cli/src/config/paths.ts:28 Mixed home/config path sources in discovery pass TRUE IGNORE Mixed fixed Global.Path.config and dynamic home reads mirror canonical configuration behavior. Read-only overlays must not redefine module-load paths or Flag precedence. No live read-only/setup mismatch is demonstrated; hypothetical HOME support is not a reason to redesign Global.Path.
14c8fb3f4ea80b44 MINOR packages/cli/test/cli/run-tool-selection.test.ts:319 Deadline SIGKILL leaks fixture grandchildren TRUE FIX Fixed owned process-group cleanup and deadline-raced output collection; actual ready-barrier controls prove child/grandchild absence before manual cleanup, including TERM-error escalation. Hostile external-adopter zombies remain an explicit bounded failure.
3e450ffe39c279a0 NIT packages/cli/src/session/prompt.ts:1278 Config allowlist: strict at top, silent under child TRUE IGNORE Strict newly requested/top-level names versus intersection under an inherited ceiling is intentional. README already says child config/model/permissions can narrow the ceiling; empty results are valid. Warning/refactor is optional, not a broken selector.
43d83b50356760b2 MINOR packages/cli/src/cli/cmd/run.ts:1221 Attach preflight fetch failure crashes runs unguarded TRUE FIX Fixed by using the existing bootstrap invocation.guard around canonical discovery. Eight actual-source controls preserve one ordered terminal error sequence, bootstrap classification, strict syntax precedence and zero receiver/setup effects.
5fb950db373775ce MINOR packages/cli/src/session/prompt.ts:975 Selection facade duplicated 3x with divergent messages TRUE IGNORE Facades repeat construction and have differently worded errors, but names are frozen and each dispatch checks its intended context. No divergent authorization or execution failure is established. A shared factory would broaden this corrective change.
68a90ca235ec82c3 MINOR packages/cli/src/session/compaction.ts:1 Relative imports drift from the @/ alias convention TRUE IGNORE Relative/alias import styles are mixed. This is optional style with no observed wrong module identity or resolution failure; no broad import churn.
6c664c301a93ca9f MINOR packages/cli/src/config/config.ts:137 Empty if(token){} dead block kept on rewritten line TRUE IGNORE The empty block is cosmetic, but Control.token is not pure: it can refresh OAuth state and update the database. Do not delete the setup call as the bot suggests. Removing only dead syntax is optional.
77392da6537297c7 NIT packages/cli/src/cli/cmd/run.ts:493 Parse error masks clearer --attach rejection TRUE IGNORE Malformed explicit selection and unsupported attach both fail before config discovery. Choosing syntax validation first is a valid error-precedence decision and preserves the accepted gate.
97cac2e4521fdbf6 MINOR packages/cli/src/session/prompt.ts:485 Restricted resume with pending subtask hard-fails run loop FALSE IGNORE The guard intentionally refuses a queued Task when the active selection omits native task or selects a shadowing custom identity. Auto-including task would widen user authority; silently dropping a pending subtask changes persisted semantics. A caller can choose an eligible native-task selection, so dropping --tools is not the only recovery.
a704c3e524cb67c3 NIT packages/cli/src/session/compaction.ts:264 Tautological role check on pre-filtered user msg TRUE IGNORE The role test is redundant at runtime after findLast, but it also supplies explicit discriminant narrowing before reading user-only toolSelection. There is no behavioral defect; leave it rather than make an unvalidated type simplification.
b839f9f5f6751a41 MINOR packages/cli/test/cli/run-tool-selection.test.ts:39 Install guard only blocks bun; other PMs escape TRUE DEFER The spawn guard covers known Bun add/install only, not arbitrary other package managers or sockets. Current production installer uses the covered Bun path; a newly introduced non-Bun installer is a coverage risk, not demonstrated current unauthorized installation. Decide a known-manager denial policy and positive controls separately; do not claim the fetch/spawn guard is an OS network sandbox.
bd1894d0c5225de0 MINOR packages/cli/test/cli/run-tool-selection-attach.test.ts:303 Attach tests lack timeouts vs 10s child deadline FALSE IGNORE The canonical package test command is bun test --timeout30000, invoked by CI through Turbo. It exceeds the helper10-second deadline. The claim assumes a bare5-second runner and omits the committed package script/bunfig guidance. Raw ad-hoc bun test is not evidence of a canonical timeout defect.
c2739377239dbaf3 MINOR packages/cli/src/session/prompt.ts:481 New run-loop guards throw bare Error, not NamedError FALSE IGNORE Plain Error is still classified as reason unknown with its message and propagated through reject/report/complete and invocation.run. It is not an unstructured/lost session rejection. Schema mode failing when its protocol permission is denied is deliberate; work selection never grants protocol permission. A specialized NamedError is optional API design, not required by this contract.
c5e29e519a9bfa73 MINOR packages/cli/test/cli/run-tool-selection.test.ts:716 Vacuous work-marker assertion in omitted-task branch TRUE IGNORE One omitted-Task marker assertion cannot fail because its writer is absent, but nonzero exit and zero provider requests still verify rejection. Installing the collision in both branches would replace the native-omission case with a second collision case. Optional oracle cleanup must retain that distinct coverage.
ccb7effdaa767eda MINOR packages/cli/src/session/llm.ts:188 structured.reject runs even when repair returns null FALSE IGNORE Source records one rejection for the malformed StructuredOutput call; returning no invalid-tool repair does not itself make that rejection wrong or duplicate its attempt. Processor tool-error persistence does not call reject again. Moving the guard first would suppress real schema rejection accounting. No incorrect retry budget is demonstrated; preserve native/custom identity and validate an actual SDK case before changing accounting.
ce06836adcda5d9d MINOR packages/cli/test/cli/run-tool-selection.test.ts:1197 Unfalsifiable >=1 request-count assertions TRUE IGNORE The assertions specify a lower bound rather than exact turn count. They are not wholly unfalsifiable: zero fails, marker/catalog oracles cover the targeted property, and the fixture deadline rejects a runaway invocation. Exact counts are optional strengthening; do not guess the bot two-turn value across error/parent-child modes.
e135c75913de2eb6 MINOR packages/cli/src/session/prompt.ts:1269 Config allowlist forces model fetch during noReply record FALSE IGNORE Active selections deliberately validate their model-specific catalog before persistence. Omitted/default-null selection retains noReply recording without a provider; comments and tests explicitly distinguish it. getModel resolves provider state/catalog, not model inference. Skipping validation or persisting an unchecked active snapshot would weaken the accepted contract.
e43d2fefe9aca096 MINOR packages/cli/src/session/prompt.ts:1277 Selection validation ignores permission-denied tools FALSE IGNORE Availability validation and permission enforcement are intentionally separate. A selected denied tool remains denied and is filtered from effective names. README explicitly preserves permission rules; selection is not a permission grant and no nonempty-effective-set promise exists. Do not add failure or warning semantics for a valid intersection.
fecd39b1aa372d85 NIT packages/cli/src/session/prompt.ts:1284 Empty allowlist array escapes Object.freeze FALSE IGNORE JavaScript empty arrays are truthy. The existing if(selection.allowlist) therefore freezes[]; the factual premise is false. No mutation workaround is needed.

Not addressed

Broader package-manager coverage in the private test guard is deferred for a scoped policy/control review (manager-owned, first review 23 October). The current installer uses the covered Bun path. These partial fetch/spawn controls are not an OS network sandbox; no broader confinement claim is made. No new provider/network timeout, permission widening, Task auto-inclusion or broad facade/import refactor is introduced.

Verified false

Pending-Task recovery must not widen selection; canonical test timeout is 30 seconds; generic errors already reach structured reporting; one malformed structured call is not double-counted; active selections intentionally validate before noReply persistence; selection never grants denied permissions; empty arrays are truthy and already frozen.

The table uses exact returned native finding IDs. All native githubCommentId values were null, so identity was verified against each unique review, file, line, native severity, title and full claim/suggestion. The native-only compaction row is included even though the older parser omitted it.

@byapparov
byapparov changed the base branch from main to fix/issue-244-tool-guidance-20261021 October 21, 2026 12:44
Base automatically changed from fix/issue-244-tool-guidance-20261021 to main October 21, 2026 12:54
? undefined
: await Promise.resolve()
.then(() => ToolSelection.parse(requested))
.catch((error) => fail(error.message, "INVOCATION_INVALID_ARGUMENTS"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Invalid --tools prints raw ZodError JSON to user.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/cli/cmd/run.ts:493):

Problem: Invalid --tools prints raw ZodError JSON to user
Detail: toolAllowlist is computed via Promise.resolve().then(() => ToolSelection.parse(requested)).catch((error) => fail(error.message, …)). ToolSelection.parse throws a zod ZodError (Names.parse on the split array), and ZodError.message is the serialized JSON issues array, so `aictrl run --tools "read, bash"` (or "a,,b") prints a raw JSON blob on stderr instead of the authored "Tool names must not contain surrounding whitespace" text, unlike every neighbouring hand-written fail() message in this handler. The same raw ZodError JSON reaches users via Config.readToolSelection (Names.parse) on the --attach preflight path.
Suggested fix: Map zod issues to readable text before failing, e.g. .catch((error) => fail(error instanceof z.ZodError ? error.issues.map((i) => i.message).join("; ") : String(error), "INVOCATION_INVALID_ARGUMENTS")), or have ToolSelection.parse throw a plain Error carrying the first issue message. A plain try/catch would also drop the odd Promise.resolve().then wrapper.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

toolAllowlist is computed via Promise.resolve().then(() => ToolSelection.parse(requested)).catch((error) => fail(error.message, …)). ToolSelection.parse throws a zod ZodError (Names.parse on the split array), and ZodError.message is the serialized JSON issues array, so aictrl run --tools "read, bash" (or "a,,b") prints a raw JSON blob on stderr instead of the authored "Tool names must not contain surrounding whitespace" text, unlike every neighbouring hand-written fail() message in this handler. The same raw ZodError JSON reaches users via Config.readToolSelection (Names.parse) on the --attach preflight path.

    const toolAllowlist =
      requested === undefined
        ? undefined
        : await Promise.resolve()
            .then(() => ToolSelection.parse(requested))
            .catch((error) => fail(error.message, "INVOCATION_INVALID_ARGUMENTS"))

    if (args.attach && requested !== undefined) {

if (options.setup) {
await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result)
await fs.unlink(legacy)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Readonly attach preflight still imports legacy config JS.

Suggested change
}
Hoist the whole legacy branch behind `if (options.setup)` so readonly discovery never imports/executes the legacy module (it would then simply not contribute config in readonly mode), or read the legacy data without executing code.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/config/config.ts:1224):

Problem: Readonly attach preflight still imports legacy config JS
Detail: In loadGlobal, the legacy `Global.Path.config/config` module is dynamically imported unconditionally; only the write-back (writeJson + unlink) is guarded by options.setup. The readonly discovery pass used as the --attach preflight (resolve(false) via Config.readToolSelection) therefore still executes the legacy config module — arbitrary user JS with top-level side effects — during what config.ts and the README describe as a pass that "never … initializes plugins/dependencies or rewrites config". Executing user config code is a side effect the preflight invariant claims not to have (normal runs import it too, but there it is expected setup).
Suggested fix: Hoist the whole legacy branch behind `if (options.setup)` so readonly discovery never imports/executes the legacy module (it would then simply not contribute config in readonly mode), or read the legacy data without executing code.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

In loadGlobal, the legacy Global.Path.config/config module is dynamically imported unconditionally; only the write-back (writeJson + unlink) is guarded by options.setup. The readonly discovery pass used as the --attach preflight (resolve(false) via Config.readToolSelection) therefore still executes the legacy config module — arbitrary user JS with top-level side effects — during what config.ts and the README describe as a pass that "never … initializes plugins/dependencies or rewrites config". Executing user config code is a side effect the preflight invariant claims not to have (normal runs import it too, but there it is expected setup).

          result = mergeConfig(result, rest)\n          if (options.setup) {\n            await Filesystem.writeJson(path.join(Global.Path.config, \"config.json\"), result)\n            await fs.unlink(legacy)\n          }

abort,
callID: part.callID,
extra: { bypassAgentCheck: true },
selection: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Subtask selection facade misses Object.freeze.

--- a/packages/cli/src/session/prompt.ts
+++ b/packages/cli/src/session/prompt.ts
@@ -578,7 +578,7 @@
-          selection: {
+          selection: Object.freeze({
             names,
             restricted: selection.allowlist !== null,
             assert(name) {
@@ -586,7 +586,7 @@
             async execute() {
               throw new Error("Nested direct dispatch is unavailable before subtask setup")
             },
-          },
+          }),
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/session/prompt.ts:578):

Problem: Subtask selection facade misses Object.freeze
Detail: Of the three selection facades this PR constructs, resolveTools calls Object.freeze(selection) and registry.ts wraps its plugin copy in Object.freeze({...}), but the pending-subtask facade injected into the Tool.Context in run() is left mutable. Anything holding that ctx (including TaskTool, which derives the child ceiling from ctx.selection?.restricted/names) can replace assert/names/restricted before the ceiling is computed, contradicting the README contract "The facade and names are immutable" and continuing the per-copy drift already noted for this triplicated facade.
Suggested fix: Wrap the literal in Object.freeze (names already comes from the frozen ToolCatalog.names array); better, extract the shared frozen facade factory the duplication finding asks for.

Suggested patch:
--- a/packages/cli/src/session/prompt.ts
+++ b/packages/cli/src/session/prompt.ts
@@ -578,7 +578,7 @@
-          selection: {
+          selection: Object.freeze({
             names,
             restricted: selection.allowlist !== null,
             assert(name) {
@@ -586,7 +586,7 @@
             async execute() {
               throw new Error("Nested direct dispatch is unavailable before subtask setup")
             },
-          },
+          }),


Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Of the three selection facades this PR constructs, resolveTools calls Object.freeze(selection) and registry.ts wraps its plugin copy in Object.freeze({...}), but the pending-subtask facade injected into the Tool.Context in run() is left mutable. Anything holding that ctx (including TaskTool, which derives the child ceiling from ctx.selection?.restricted/names) can replace assert/names/restricted before the ceiling is computed, contradicting the README contract "The facade and names are immutable" and continuing the per-copy drift already noted for this triplicated facade.

          extra: { bypassAgentCheck: true },
          selection: {
            names,
            restricted: selection.allowlist !== null,
            assert(name) {
              if (!names.includes(name))
                throw new Error(`Tool '${name}' is excluded by this invocation's tool selection/permissions`)
            },
            async execute() {
              throw new Error("Nested direct dispatch is unavailable before subtask setup")
            },
          },

if (catalog) {
const controls = [
...ToolCatalog.controls(catalog),
...(input.format?.type === "json_schema" ? ["StructuredOutput"] : []),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Schema-mode controls match by name, not identity.

Suggested change
...(input.format?.type === "json_schema" ? ["StructuredOutput"] : []),
Either qualify the README sentence ("outside schema mode, custom tools with those names are still work tools") or make the controls exclusion identity-aware (exclude only the harness-created StructuredOutput definition, not the name) in ToolCatalog.names/ToolSelection.validate.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/src/session/prompt.ts:1275):

Problem: Schema-mode controls match by name, not identity
Detail: Schema-mode controls are matched by bare name (contract ? ["StructuredOutput"] : [], both here and in run()'s ToolCatalog.names call, plus validate()'s controls), while identity checks elsewhere correctly use ToolGuidance.isProtocol/builtin markers. A user-defined custom tool literally named "StructuredOutput" (a valid work tool outside schema mode — the PR's own test declares it "executes as work outside schema mode") is therefore excluded from effective names and rejected with "is a protocol/control tool, not a selectable work tool" whenever --output-schema is used. This contradicts the README's blanket claim "Custom tools with those names are still work tools; protocol identity belongs to the harness-created definition."
Suggested fix: Either qualify the README sentence ("outside schema mode, custom tools with those names are still work tools") or make the controls exclusion identity-aware (exclude only the harness-created StructuredOutput definition, not the name) in ToolCatalog.names/ToolSelection.validate.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Schema-mode controls are matched by bare name (contract ? ["StructuredOutput"] : [], both here and in run()'s ToolCatalog.names call, plus validate()'s controls), while identity checks elsewhere correctly use ToolGuidance.isProtocol/builtin markers. A user-defined custom tool literally named "StructuredOutput" (a valid work tool outside schema mode — the PR's own test declares it "executes as work outside schema mode") is therefore excluded from effective names and rejected with "is a protocol/control tool, not a selectable work tool" whenever --output-schema is used. This contradicts the README's blanket claim "Custom tools with those names are still work tools; protocol identity belongs to the harness-created definition."

      const controls = [\n        ...ToolCatalog.controls(catalog),\n        ...(input.format?.type === \"json_schema\" ? [\"StructuredOutput\"] : []),\n      ]\n      ToolSelection.validate(

.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
.find((event) => event.type === "tool_use" && event.part.tool === "delegate")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 JSON.parse of all stdout lines masks real regressions.

Suggested change
.find((event) => event.type === "tool_use" && event.part.tool === "delegate")
Parse defensively: collect per-line parse failures and expect(parsedFailures).toEqual([]) (or throw new Error("unparsable stdout: " + result.stdout) before the deep-equal), so unexpected CLI output surfaces as an actionable assertion rather than a SyntaxError.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/test/cli/run-tool-selection.test.ts:1148):

Problem: JSON.parse of all stdout lines masks real regressions
Detail: The public-facade/MCP tests JSON.parse every stdout line (and critical-noreply does JSON.parse(result.stdout.trim())). Any stray non-JSON stdout line the CLI ever emits (version notice, deprecation banner, partial flush) makes JSON.parse throw an opaque SyntaxError instead of an expect failure showing the actual output — masking the real regression (unexpected stdout content, or a changed role/model shape) and hiding the deep-equal diff that would explain it.
Suggested fix: Parse defensively: collect per-line parse failures and expect(parsedFailures).toEqual([]) (or throw new Error("unparsable stdout: " + result.stdout) before the deep-equal), so unexpected CLI output surfaces as an actionable assertion rather than a SyntaxError.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The public-facade/MCP tests JSON.parse every stdout line (and critical-noreply does JSON.parse(result.stdout.trim())). Any stray non-JSON stdout line the CLI ever emits (version notice, deprecation banner, partial flush) makes JSON.parse throw an opaque SyntaxError instead of an expect failure showing the actual output — masking the real regression (unexpected stdout content, or a changed role/model shape) and hiding the deep-equal diff that would explain it.

      const event = result.stdout\n        .split(\"\\n\")\n        .filter(Boolean)\n        .map((line) => JSON.parse(line))\n        .find((event) => event.type === \"tool_use\" && event.part.tool === \"delegate\")

.filter(Boolean)
.map((line) => JSON.parse(line) as { origin: string; path: string; allowed: boolean })
if (input.codex)
requests.push(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Deadline error undercounts codex requests.

Suggested change
requests.push(
Inside deadlineFailure() (or the deadline catch), read and count codexLog lines before formatting the message so the diagnostics include intercepted codex requests.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/test/cli/run-tool-selection.test.ts:391):

Problem: Deadline error undercounts codex requests
Detail: When the deadline promise wins the race, run() throws deadlineFailure() immediately and the codexLog merge into `requests` (plus the network-ledger assertion) never executes. For codex runs the thrown "providerRequests=N" therefore only counts local server requests: a hung Codex child reports providerRequests=0 even though the codex capture file may contain the stuck request, actively misleading triage of exactly the hangs this deadline exists to diagnose.
Suggested fix: Inside deadlineFailure() (or the deadline catch), read and count codexLog lines before formatting the message so the diagnostics include intercepted codex requests.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

When the deadline promise wins the race, run() throws deadlineFailure() immediately and the codexLog merge into requests (plus the network-ledger assertion) never executes. For codex runs the thrown "providerRequests=N" therefore only counts local server requests: a hung Codex child reports providerRequests=0 even though the codex capture file may contain the stuck request, actively misleading triage of exactly the hangs this deadline exists to diagnose.

    if (input.codex)\n      requests.push(\n        ...(await Bun.file(codexLog).text())\n          .split(\"\\n\")\n          .filter(Boolean)\n          .map((line) => JSON.parse(line) as Request),\n      )

sessionID: event.sessionID,
messageID: event.part.messageID,
agent: expect.any(String),
directory: path.dirname(result.command[3]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ command[3] is preload path; assertion passes by accident.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/test/cli/run-tool-selection.test.ts:1153):

Problem: command[3] is preload path; assertion passes by accident
Detail: The public-mcp assertion expects directory: path.dirname(result.command[3]). The command array is [execPath, "run", "--preload", preload, "--conditions=browser", selected, …], so command[3] is the network-guard preload file, not the CLI entry. It passes only because dirname(preload) happens to equal the child cwd (tmp.path). The index reads as the entry path, and moving the preload file into a subdirectory would break the assertion spuriously — or stop comparing against the actual cwd.
Suggested fix: Have run() expose the spawn cwd (e.g. result.cwd) and assert against it explicitly instead of deriving it from an unrelated command-array index.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The public-mcp assertion expects directory: path.dirname(result.command[3]). The command array is [execPath, "run", "--preload", preload, "--conditions=browser", selected, …], so command[3] is the network-guard preload file, not the CLI entry. It passes only because dirname(preload) happens to equal the child cwd (tmp.path). The index reads as the entry path, and moving the preload file into a subdirectory would break the assertion spuriously — or stop comparing against the actual cwd.

      expect(result.markers[\"caller-marker.json\"]).toMatchObject({\n        sessionID: event.sessionID,\n        messageID: event.part.messageID,\n        agent: expect.any(String),\n        directory: path.dirname(result.command[3]),


async function run(input: Input) {
const fixturePath = [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(path.delimiter)
const ripgrep = Bun.which("rg", { PATH: fixturePath })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Missing ripgrep fails all ~75 tests, no skip path.

Suggested change
const ripgrep = Bun.which("rg", { PATH: fixturePath })
Hoist the which() check to module scope and gate the suites with describe.skipIf(!ripgrep) (keeping the actionable message in the skip reason), or fail once in a module-level setup test.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/test/cli/run-tool-selection.test.ts:62):

Problem: Missing ripgrep fails all ~75 tests, no skip path
Detail: Bun.which("rg") failure throws inside run(), so in an environment without ripgrep every test in this ~75-test file fails individually with the same error rather than skipping or failing once at module scope. That floods CI/local output with duplicates (and can swallow unrelated failures in the same run), and re-runs the which() lookup per test.
Suggested fix: Hoist the which() check to module scope and gate the suites with describe.skipIf(!ripgrep) (keeping the actionable message in the skip reason), or fail once in a module-level setup test.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Bun.which("rg") failure throws inside run(), so in an environment without ripgrep every test in this ~75-test file fails individually with the same error rather than skipping or failing once at module scope. That floods CI/local output with duplicates (and can swallow unrelated failures in the same run), and re-runs the which() lookup per test.

  const ripgrep = Bun.which(\"rg\", { PATH: fixturePath })\n  if (!ripgrep)\n    throw new Error(\n      \"Selector CLI fixtures require a real ripgrep executable on the child PATH. On Ubuntu install ripgrep, then verify rg --version; fixture network downloads are forbidden.\",\n    )

const chunks = [
{
id: "selector-fixture",
object: "chat.completion.chunk",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Scripted call reads stale requests.at(-1) after barrier.

Suggested change
object: "chat.completion.chunk",
Capture the parsed request in a local const before any barrier await (const req = await request.json(); requests.push(req)) and pass that request to the scripted callback instead of re-reading requests.at(-1).
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/cli/test/cli/run-tool-selection.test.ts:112):

Problem: Scripted call reads stale requests.at(-1) after barrier
Detail: The fixture server pushes the parsed request, may await a runtime barrier (concurrent-sessions, sibling-children, cancel-model, overlap-child-resume), and only afterwards shifts the scripted call and evaluates it against requests.at(-1) — which after a barrier await is whichever request landed last across all in-flight sessions, not the one being answered. It currently returns the intended null only because that stale request happens to contain an assistant message; any future non-null scripted response for a barrier-held request gets cross-wired to the wrong session, yielding wrong tool ceilings and false results.
Suggested fix: Capture the parsed request in a local const before any barrier await (const req = await request.json(); requests.push(req)) and pass that request to the scripted callback instead of re-reading requests.at(-1).

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The fixture server pushes the parsed request, may await a runtime barrier (concurrent-sessions, sibling-children, cancel-model, overlap-child-resume), and only afterwards shifts the scripted call and evaluates it against requests.at(-1) — which after a barrier await is whichever request landed last across all in-flight sessions, not the one being answered. It currently returns the intended null only because that stale request happens to contain an assistant message; any future non-null scripted response for a barrier-held request gets cross-wired to the wrong session, yielding wrong tool ceilings and false results.

      requests.push((await request.json()) as Request)\n      if (input.runtime === \"concurrent-sessions\") {\n        if (requests.length >= 2) releaseConcurrent?.()\n        await concurrentBarrier\n      }\n…\n      const scripted = calls.shift()\n      const call = typeof scripted === \"function\" ? scripted(requests) : scripted

readonly restricted: boolean
assert(name: string): void
execute(name: string, args: unknown): Promise<{ title: string; output: string; metadata: Record<string, unknown> }>
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Plugin selection.execute type omits attachments.

Suggested change
}
Add `attachments?: unknown[]` (or the SDK file-part shape) to the plugin selection.execute return type so the public facade matches Tool.Context.selection.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #254, packages/plugin/src/tool.ts:25):

Problem: Plugin selection.execute type omits attachments
Detail: The internal Tool.Context.selection.execute resolves to { title, metadata, output, attachments?: FilePart-like[] }, but the published plugin ToolContext.selection.execute return type omits attachments. The runtime object (registry.ts delegates to the CLI facade) does return attachments for tools that produce them, so plugins performing nested dispatch lose them from the type level — a shape-parity gap between the internal and public facades this PR introduces.
Suggested fix: Add `attachments?: unknown[]` (or the SDK file-part shape) to the plugin selection.execute return type so the public facade matches Tool.Context.selection.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The internal Tool.Context.selection.execute resolves to { title, metadata, output, attachments?: FilePart-like[] }, but the published plugin ToolContext.selection.execute return type omits attachments. The runtime object (registry.ts delegates to the CLI facade) does return attachments for tools that produce them, so plugins performing nested dispatch lose them from the type level — a shape-parity gap between the internal and public facades this PR introduces.

  selection?: {\n    readonly names: readonly string[]\n    readonly restricted: boolean\n    assert(name: string): void\n    execute(name: string, args: unknown): Promise<{ title: string; output: string; metadata: Record<string, unknown> }>\n  }

@aictrl-dev

aictrl-dev Bot commented Oct 21, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 7 · ⚪ 6 · 0/13 resolved

  • 🟡 packages/cli/src/cli/cmd/run.ts:493 — Invalid --tools prints raw ZodError JSON to user
  • 🟡 packages/cli/src/config/config.ts:1224 — Readonly attach preflight still imports legacy config JS
  • 🟡 packages/cli/src/session/prompt.ts:578 — Subtask selection facade misses Object.freeze
  • 🟡 packages/cli/src/session/prompt.ts:1275 — Schema-mode controls match by name, not identity
  • ⚪ packages/cli/src/session/system.ts:6 — system.ts imports guidance relatively; peers use @/
  • 🟡 packages/cli/src/tool/truncation.ts:43 — Dead _agent param kept; callers still pass it
  • ⚪ packages/cli/test/cli/run-tool-selection.test.ts:62 — Missing ripgrep fails all ~75 tests, no skip path
  • ⚪ packages/cli/test/cli/run-tool-selection.test.ts:112 — Scripted call reads stale requests.at(-1) after barrier
  • ⚪ packages/cli/test/cli/run-tool-selection.test.ts:391 — Deadline error undercounts codex requests
  • 🟡 packages/cli/test/cli/run-tool-selection.test.ts:1148 — JSON.parse of all stdout lines masks real regressions
  • ⚪ packages/cli/test/cli/run-tool-selection.test.ts:1153 — command[3] is preload path; assertion passes by accident
  • 🟡 packages/cli/test/cli/run-tool-selection.test.ts:1795 — Deadline canary regex also matches cleanup-failure errors
  • ⚪ packages/plugin/src/tool.ts:25 — Plugin selection.execute type omits attachments
🤖 Fix all 13 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #254 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.ts:493 — Invalid --tools prints raw ZodError JSON to user
   Detail: toolAllowlist is computed via Promise.resolve().then(() => ToolSelection.parse(requested)).catch((error) => fail(error.message, …)). ToolSelection.parse throws a zod ZodError (Names.parse on the split array), and ZodError.message is the serialized JSON issues array, so `aictrl run --tools "read, bash"` (or "a,,b") prints a raw JSON blob on stderr instead of the authored "Tool names must not contain surrounding whitespace" text, unlike every neighbouring hand-written fail() message in this handler. The same raw ZodError JSON reaches users via Config.readToolSelection (Names.parse) on the --attach preflight path.
   Suggested fix: Map zod issues to readable text before failing, e.g. .catch((error) => fail(error instanceof z.ZodError ? error.issues.map((i) => i.message).join("; ") : String(error), "INVOCATION_INVALID_ARGUMENTS")), or have ToolSelection.parse throw a plain Error carrying the first issue message. A plain try/catch would also drop the odd Promise.resolve().then wrapper.
2. packages/cli/src/config/config.ts:1224 — Readonly attach preflight still imports legacy config JS
   Detail: In loadGlobal, the legacy `Global.Path.config/config` module is dynamically imported unconditionally; only the write-back (writeJson + unlink) is guarded by options.setup. The readonly discovery pass used as the --attach preflight (resolve(false) via Config.readToolSelection) therefore still executes the legacy config module — arbitrary user JS with top-level side effects — during what config.ts and the README describe as a pass that "never … initializes plugins/dependencies or rewrites config". Executing user config code is a side effect the preflight invariant claims not to have (normal runs import it too, but there it is expected setup).
   Suggested fix: Hoist the whole legacy branch behind `if (options.setup)` so readonly discovery never imports/executes the legacy module (it would then simply not contribute config in readonly mode), or read the legacy data without executing code.
3. packages/cli/src/session/prompt.ts:578 — Subtask selection facade misses Object.freeze
   Detail: Of the three selection facades this PR constructs, resolveTools calls Object.freeze(selection) and registry.ts wraps its plugin copy in Object.freeze({...}), but the pending-subtask facade injected into the Tool.Context in run() is left mutable. Anything holding that ctx (including TaskTool, which derives the child ceiling from ctx.selection?.restricted/names) can replace assert/names/restricted before the ceiling is computed, contradicting the README contract "The facade and names are immutable" and continuing the per-copy drift already noted for this triplicated facade.
   Suggested fix: Wrap the literal in Object.freeze (names already comes from the frozen ToolCatalog.names array); better, extract the shared frozen facade factory the duplication finding asks for.
4. packages/cli/src/session/prompt.ts:1275 — Schema-mode controls match by name, not identity
   Detail: Schema-mode controls are matched by bare name (contract ? ["StructuredOutput"] : [], both here and in run()'s ToolCatalog.names call, plus validate()'s controls), while identity checks elsewhere correctly use ToolGuidance.isProtocol/builtin markers. A user-defined custom tool literally named "StructuredOutput" (a valid work tool outside schema mode — the PR's own test declares it "executes as work outside schema mode") is therefore excluded from effective names and rejected with "is a protocol/control tool, not a selectable work tool" whenever --output-schema is used. This contradicts the README's blanket claim "Custom tools with those names are still work tools; protocol identity belongs to the harness-created definition."
   Suggested fix: Either qualify the README sentence ("outside schema mode, custom tools with those names are still work tools") or make the controls exclusion identity-aware (exclude only the harness-created StructuredOutput definition, not the name) in ToolCatalog.names/ToolSelection.validate.
5. packages/cli/src/session/system.ts:6 — system.ts imports guidance relatively; peers use @/
   Detail: system.ts adds `import { ToolGuidance } from "../tool/guidance"` while llm.ts and agent.ts import the exact same new module as "@/tool/guidance". This is one more instance of the relative-vs-alias drift for the new tool modules (already flagged for compaction/message-v2/prompt/tool-catalog/run), in a file that itself already mixes styles — a fix sweeping the known files would miss this one.
   Suggested fix: Use `import { ToolGuidance } from "@/tool/guidance"` to match llm.ts/agent.ts, ideally in the same pass that normalizes the other relative imports of the new modules.
6. packages/cli/src/tool/truncation.ts:43 — Dead _agent param kept; callers still pass it
   Detail: hasTaskTool and the agent-aware truncation hint were removed, but the third parameter survives as an unused `_agent?: Agent.Info`, and call sites were not swept: registry.ts still passes `initCtx?.agent` to Truncate.output inside the plugin tool wrapper, and tests were rewritten to assert the parameter is inert. Dead code is preserved end-to-end solely to prove it does nothing, and the `import type { Agent }` in truncation.ts now exists only for this dead parameter.
   Suggested fix: Remove the `_agent` parameter (and the now-unused Agent import) from Truncate.output, and drop the argument at the call sites (registry.ts: `Truncate.output(result, {})`), simplifying the tests that pass an agent only to assert it is ignored.
7. packages/cli/test/cli/run-tool-selection.test.ts:62 — Missing ripgrep fails all ~75 tests, no skip path
   Detail: Bun.which("rg") failure throws inside run(), so in an environment without ripgrep every test in this ~75-test file fails individually with the same error rather than skipping or failing once at module scope. That floods CI/local output with duplicates (and can swallow unrelated failures in the same run), and re-runs the which() lookup per test.
   Suggested fix: Hoist the which() check to module scope and gate the suites with describe.skipIf(!ripgrep) (keeping the actionable message in the skip reason), or fail once in a module-level setup test.
8. packages/cli/test/cli/run-tool-selection.test.ts:112 — Scripted call reads stale requests.at(-1) after barrier
   Detail: The fixture server pushes the parsed request, may await a runtime barrier (concurrent-sessions, sibling-children, cancel-model, overlap-child-resume), and only afterwards shifts the scripted call and evaluates it against requests.at(-1) — which after a barrier await is whichever request landed last across all in-flight sessions, not the one being answered. It currently returns the intended null only because that stale request happens to contain an assistant message; any future non-null scripted response for a barrier-held request gets cross-wired to the wrong session, yielding wrong tool ceilings and false results.
   Suggested fix: Capture the parsed request in a local const before any barrier await (const req = await request.json(); requests.push(req)) and pass that request to the scripted callback instead of re-reading requests.at(-1).
9. packages/cli/test/cli/run-tool-selection.test.ts:391 — Deadline error undercounts codex requests
   Detail: When the deadline promise wins the race, run() throws deadlineFailure() immediately and the codexLog merge into `requests` (plus the network-ledger assertion) never executes. For codex runs the thrown "providerRequests=N" therefore only counts local server requests: a hung Codex child reports providerRequests=0 even though the codex capture file may contain the stuck request, actively misleading triage of exactly the hangs this deadline exists to diagnose.
   Suggested fix: Inside deadlineFailure() (or the deadline catch), read and count codexLog lines before formatting the message so the diagnostics include intercepted codex requests.
10. packages/cli/test/cli/run-tool-selection.test.ts:1148 — JSON.parse of all stdout lines masks real regressions
   Detail: The public-facade/MCP tests JSON.parse every stdout line (and critical-noreply does JSON.parse(result.stdout.trim())). Any stray non-JSON stdout line the CLI ever emits (version notice, deprecation banner, partial flush) makes JSON.parse throw an opaque SyntaxError instead of an expect failure showing the actual output — masking the real regression (unexpected stdout content, or a changed role/model shape) and hiding the deep-equal diff that would explain it.
   Suggested fix: Parse defensively: collect per-line parse failures and expect(parsedFailures).toEqual([]) (or throw new Error("unparsable stdout: " + result.stdout) before the deep-equal), so unexpected CLI output surfaces as an actionable assertion rather than a SyntaxError.
11. packages/cli/test/cli/run-tool-selection.test.ts:1153 — command[3] is preload path; assertion passes by accident
   Detail: The public-mcp assertion expects directory: path.dirname(result.command[3]). The command array is [execPath, "run", "--preload", preload, "--conditions=browser", selected, …], so command[3] is the network-guard preload file, not the CLI entry. It passes only because dirname(preload) happens to equal the child cwd (tmp.path). The index reads as the entry path, and moving the preload file into a subdirectory would break the assertion spuriously — or stop comparing against the actual cwd.
   Suggested fix: Have run() expose the spawn cwd (e.g. result.cwd) and assert against it explicitly instead of deriving it from an unrelated command-array index.
12. packages/cli/test/cli/run-tool-selection.test.ts:1795 — Deadline canary regex also matches cleanup-failure errors
   Detail: The forced-deadline canary asserts rejects.toThrow(/child deadline exceeded after 20ms; providerRequests=0/). When run() hits the deadline but its finally-block cleanup also fails, the thrown value is an AggregateError whose message embeds the same deadline text plus "; fixture cleanup also failed" — the regex matches that too. A regression breaking stopGroup/waitGroupGone/pipe cancellation (exactly the machinery this test exists to exercise) therefore still passes, leaving the cleanup contract unpinned where it matters most.
   Suggested fix: Capture the rejection and additionally assert it is not an AggregateError / its message does not contain "fixture cleanup failed", so group-termination regressions fail the canary instead of hiding inside the deadline text.
13. packages/plugin/src/tool.ts:25 — Plugin selection.execute type omits attachments
   Detail: The internal Tool.Context.selection.execute resolves to { title, metadata, output, attachments?: FilePart-like[] }, but the published plugin ToolContext.selection.execute return type omits attachments. The runtime object (registry.ts delegates to the CLI facade) does return attachments for tools that produce them, so plugins performing nested dispatch lose them from the type level — a shape-parity gap between the internal and public facades this PR introduces.
   Suggested fix: Add `attachments?: unknown[]` (or the SDK file-part shape) to the plugin selection.execute return type so the public facade matches Tool.Context.selection.
📋 Out-of-diff findings (13)
Sev Location Finding
🟡 packages/cli/src/cli/cmd/run.ts:493 Invalid --tools prints raw ZodError JSON to user
🟡 packages/cli/src/config/config.ts:1224 Readonly attach preflight still imports legacy config JS
🟡 packages/cli/src/session/prompt.ts:578 Subtask selection facade misses Object.freeze
🟡 packages/cli/src/session/prompt.ts:1275 Schema-mode controls match by name, not identity
⚪ packages/cli/src/session/system.ts:6 system.ts imports guidance relatively; peers use @/
🟡 packages/cli/src/tool/truncation.ts:43 Dead _agent param kept; callers still pass it
⚪ packages/cli/test/cli/run-tool-selection.test.ts:62 Missing ripgrep fails all ~75 tests, no skip path
⚪ packages/cli/test/cli/run-tool-selection.test.ts:112 Scripted call reads stale requests.at(-1) after barrier
⚪ packages/cli/test/cli/run-tool-selection.test.ts:391 Deadline error undercounts codex requests
🟡 packages/cli/test/cli/run-tool-selection.test.ts:1148 JSON.parse of all stdout lines masks real regressions
⚪ packages/cli/test/cli/run-tool-selection.test.ts:1153 command[3] is preload path; assertion passes by accident
🟡 packages/cli/test/cli/run-tool-selection.test.ts:1795 Deadline canary regex also matches cleanup-failure errors
⚪ packages/plugin/src/tool.ts:25 Plugin selection.execute type omits attachments

Reviewed 27 files · 0 inline · view all 13 findings ↗ · AI-generated — verify findings before acting · AI guidance and feedback


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

byapparov commented Oct 21, 2026 •

Copy link
Copy Markdown
Member Author

All 13 fresh findings from review 5600212158 / native 96d52acb-5cc2-4fd8-ae2e-ffa44da606f0 are covered below, judged against published correction 46f98d5993ad56ec033506d5e66f5da85ccc6179. The original 19-finding response remains separate and unchanged.

Three fixed, three deferred, seven ignored (one false premise).

Finding Verdict/action Reason
1d507e7519c0b91d — Deadline error undercounts codex requests TRUE/DEFER The timeout rejection formats requests.length before the post-race codexLog merge. Intercepted Codex requests can be omitted from the deadline count. The original failure still propagates; this is diagnostic completeness, not a false pass. Read the owned capture safely after cleanup in a later bounded diagnostic change. Tracked P3; owner cli-product-engineer; review 23 October 2026 (Europe/London).
22d413838a661dbd — Invalid --tools prints raw ZodError JSON to user TRUE/DEFER Names.parse throws a ZodError; the handler forwards error.message, which serializes issues. The authored validation text is present but wrapped in JSON. Exit status and INVOCATION_INVALID_ARGUMENTS remain fail-closed. Improve issue formatting separately; replacing the Promise wrapper is not required. Tracked P3; owner cli-product-engineer; review 23 October 2026 (Europe/London).
27ca23e29e17a6cf — command[3] is preload path; assertion passes by accident TRUE/IGNORE The preload dirname equals the actual fixture cwd by construction today. Exposing cwd explicitly is optional cleanup; no incorrect current directory assertion is demonstrated.
31218060ee70af6d — JSON.parse of all stdout lines masks real regressions TRUE/IGNORE Strict JSON.parse rejects invalid stdout instead of accepting a regression. Better raw-output diagnostics are optional; do not relax NDJSON parsing or filter unexpected lines.
704b7a7f83e6bdde — Subtask selection facade misses Object.freeze TRUE/IGNORE The internal pending-subtask facade is mutable, but its current consumer is trusted built-in TaskTool, names are frozen and no plugin exposure/mutation/bypass is demonstrated. Public plugin dispatch facades remain frozen. A factory/freeze consistency cleanup is optional.
7364ded2480c655d — system.ts imports guidance relatively; peers use @/ TRUE/IGNORE The relative import exists, but system.ts already mixes relative local and alias imports. This is optional style with no behavior or integration contract consequence. Do not sweep unrelated imports in this correction.
784ea583060cf0a2 — Schema-mode controls match by name, not identity TRUE/FIX Fixed the README qualification: active schema mode reserves the StructuredOutput wire name for the harness protocol. A custom tool with that name remains selectable as work outside schema mode. No duplicate wire identity or permission grant was introduced.
873111f7989e073c — Missing ripgrep fails all ~75 tests, no skip path TRUE/IGNORE The helper intentionally throws an actionable missing-ripgrep error before spawning the fixture. CI installs ripgrep. Repeated prerequisite failures are noisy but do not establish swallowed unrelated failures; skipping would remove required coverage. Retain fail-fast behavior.
905f3c0125e2ba69 — Dead _agent param kept; callers still pass it TRUE/IGNORE The optional unused agent parameter retains caller compatibility and has no current runtime effect. Removing its public/internal signature and sweeping callers/tests is optional cleanup outside this correction.
aac12db8a8c8158d — Plugin selection.execute type omits attachments TRUE/FIX Fixed the optional public plugin attachment result type using the canonical v2 command file-part contract, including file, symbol and resource sources. Existing title/output/metadata and callers omitting attachments remain compatible. Actual consumer and bidirectional internal/public compiler controls pass.
b6730c1968aa4b2b — Scripted call reads stale requests.at(-1) after barrier TRUE/DEFER After an awaited barrier, a callback inspecting requests.at(-1) can see another request; this is a valid future per-request scripting weakness. The callback API deliberately accepts the entire Request[] history, however. Current concurrent/cancel cases have no scripted callbacks, and current sibling/overlap callbacks produce null for held child requests even without an assistant message. Source does not establish current wrong ceilings or invalid catalog assertions. A future binding change must preserve the history callback contract or introduce an explicit request argument; do not pass a single request to the current array signature. Tracked P3; owner cli-product-engineer; review 23 October 2026 (Europe/London).
d83434687c38b497 — Deadline canary regex also matches cleanup-failure errors TRUE/FIX Fixed the actual 20 ms canary to require its primary deadline error and reject AggregateError, cleanup text, cause and errors. The unchanged old matcher accepted a real injected cleanup fault; the changed public test now fails that fault at its AggregateError assertion while ordinary cleanup passes. Independent KILL and owned-process absence are verified.
fae7f96dc85e83f3 — Readonly attach preflight still imports legacy config JS FALSE/IGNORE The legacy dynamic import explicitly supplies with: {type: "toml"}. Bun uses that loader to parse extensionless TOML as data, not execute arbitrary JavaScript. Only migration writes/unlink are setup-gated; the readonly pass must still read the legacy tool_allowlist to fail closed. Hoisting the whole legacy branch behind setup would bypass that policy input. No production loader change is justified by this claim.

The legacy loader explicitly requests TOML parsing; see Bun’s TOML import-attribute documentation. Its read-only policy input remains available without migration writes.

Verification: the normal main integration passed 179 selector tests/four existing skips, 230 argv/stdin tests and 19 actual local-provider cases / 22 requests, workspace/wire checks, eight attach controls and three owned lifecycle controls. The follow-up changes exactly three files; the other 594 tracked entries retain the tested merge bytes. The changed public 20 ms canary passes ordinary cleanup and correctly fails the injected cleanup fault at its real AggregateError assertion. Canonical attachment consumers/negative shapes/internal-public compatibility and direct CLI/SDK/plugin compilers pass. Both scopes passed independent review.

Normal push hooks passed all six tasks with zero cache hits, including SDK generation/plugin build; all 597 tracked bytes and Git modes were verified unchanged afterward. Current-head CodeQL passed with zero results/errors/warnings. Hosted CI39611443485 passed 2,257 tests / 10 skips / 0 failures, with 15,719 assertions across 156 files. Actual checkout 380e994 has the same tree as published 46f98d5; all 597 tracked blob hashes and Git modes match. Six scheduled five-minute polls ended at 14:19:53 UTC without a current-head formal review. The review gate remains pending; this observation is not approval. Receiver/C1, paid-model quality/billing, native/Windows, egress-sandbox and release gates remain open.

@byapparov
byapparov merged commit ec18d10 into main Oct 21, 2026
4 checks passed
@byapparov
byapparov deleted the feat/issue-246-tool-selection-20261021 branch October 21, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 Should have - High

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant