feat(gateway): coding-agent UX — projects, shell mode, coding skill - #2702
ilblackdragon wants to merge 13 commits into
Conversation
Turns the web gateway into a coding-agent front-end: conversations
bind to a project (engine v2 `Project` reused with a `GitHubRepo`
newtype + typed `metadata` view), a `!`-prefix on the input routes
through `ToolDispatcher::dispatch("shell")` instead of the LLM, and a
new `coding-repo` skill guides branch/test/PR discipline when a
project has a GitHub repo configured.
Backend
- `GitHubRepo` newtype in `ironclaw_common` with validation + 8 tests.
- `EngineProjectInfo` widened with `workspace_path` + typed
`ProjectMetadataView { github_repo, default_branch }`. Single
`EngineProjectInfo::from_project` construction path so the gateway
read and project-admin write paths cannot drift.
- Bridge helpers `create_engine_project`, `update_engine_project`,
`set_conversation_project` with shared `ProjectUpsertFields`.
- Four dispatcher-compliant tools in `src/tools/builtin/project_admin.rs`:
`project_create`, `project_update`, `project_set_active`,
`project_assign_thread`. Active pointer persists as a workspace
MemoryDoc at `projects/_active.json`.
- Gateway endpoints `POST/PATCH/DELETE /api/engine/projects`,
`GET|POST /api/engine/projects/active`,
`POST /api/chat/threads/{id}/project`. All mutations route through
the dispatcher; the active-pointer read is annotated
`// dispatch-exempt: read aggregation`.
- `ProjectContextCache` with per-field TTLs (branch 5s, dirty 5s,
PR 60s) refreshes via the shell tool dispatcher so the thread-list
endpoint never blocks on `gh`.
- `ThreadInfo.project: ThreadProjectContext` populated by a shared
`thread_project_context` helper in `handlers/engine.rs`.
- `ChatSendMode::Shell` on `/api/chat/send`, `AppEvent::ShellCommand`
+ `AppEvent::ShellOutput` SSE events, `MessageRole::Shell`
persistence, and `TurnInfo.shell` pairing in
`build_turns_from_db_messages` so history replay restores shell
turns as a distinct monospace card.
- `ironclaw_skills` parser extension: `activation.context.{require_project_field, include_git_context}`
recognised with warn-and-continue semantics. New
`prefilter_skills_with_context` variant applies the gate; legacy
`prefilter_skills` delegates with the permissive default context.
- The `coding-repo` skill declares `require_project_field: github_repo`,
so it only fires when the active project has a repo configured.
- Agent-loop wiring: `select_active_skills` now takes a
`SkillActivationContext`; the gateway populates the project hints
(`project_has_github_repo`, `project_has_workspace_path`) in
`IncomingMessage.metadata` on every `/api/chat/send`.
UI
- Conversation-chrome bar above `#tab-chat`: project name · folder ·
branch (dirty dot) · PR chip. Click opens a modal.
- Project management modal: create / edit / set-active / assign-thread
with inline `GitHubRepo` validation.
- `!` input badge (CSS `::before` + JS toggle). `sendShellCommand`
posts `mode=shell` to `/api/chat/send`.
- Distinct `.shell-turn` turn renderer: `$ cmd` header, scrollable
stdout/stderr, exit-code chip (green 0 / red non-zero).
- History replay paints the same card from `TurnInfo.shell`.
Tests
- `tests/project_admin_integration.rs` — 5 caller-level scenarios
exercising `ProjectMetadataView`, `GitHubRepo`, and the workspace-
path resolver through the upsert surface the gateway actually calls.
- `tests/live_coding_flow.rs` — live-tier scenarios gated behind
`IRONCLAW_LIVE_TESTS=1` that clone `nearai/ironclaw` into a tempdir
and assert project round-trip, clean-clone branch/dirty invariants,
shell-tool approval policy, and multi-project folder isolation.
- `tests/live/README.md` — invocation contract for the live tier.
- `tests/e2e/scenarios/test_coding_project_flow.py` — Playwright
scenarios covering chrome render after create+active, the `!` badge
toggle, the full shell turn round-trip with reload persistence, the
no-project 409, and HTTP-boundary `GitHubRepo` rejection.
- 4 skills tests for context gating; existing suite unaffected.
Quality gate
- `cargo fmt` clean.
- `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` — zero warnings.
- `cargo test -p ironclaw_skills --lib` — 171 passing.
- `cargo test -p ironclaw --lib` — 5170+ passing (one pre-existing
telegram test flake, unrelated, passes in isolation).
- `scripts/pre-commit-safety.sh` — clean; three read-only aggregation
sites explicitly annotated `dispatch-exempt`.
- `scripts/check_gateway_boundaries.py` — no new back-edges.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Turns the web gateway into a coding-agent front-end by adding project context, ! shell-mode dispatch + rendering, and context-gated coding skills.
Changes:
- Added project administration via ToolDispatcher (create/update/set-active/assign-thread) and surfaced per-thread project context to the UI.
- Implemented
!shell mode end-to-end:/api/chat/send mode=shell, SSE events, persistence, and history replay as shell turns. - Added runtime skill activation context gating and introduced the
coding-reposkill.
Reviewed changes
Copilot reviewed 42 out of 42 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/ws_gateway_integration.rs | Updates test gateway state init for new project_context_cache field. |
| tests/support/gateway_workflow_harness.rs | Updates harness state init for new project_context_cache field. |
| tests/project_admin_integration.rs | Adds integration tests for project metadata view, GitHubRepo validation, and workspace-path resolution. |
| tests/openai_compat_integration.rs | Updates test gateway state init for new project_context_cache field. |
| tests/oauth_greeting_integration.rs | Updates test gateway state init for new project_context_cache field. |
| tests/multi_tenant_integration.rs | Updates test gateway state init for new project_context_cache field. |
| tests/live_coding_flow.rs | Adds ignored live-tier Rust scenarios for project + git context + shell-mode behavior. |
| tests/live/README.md | Documents live-tier test contract and invocation. |
| tests/e2e/scenarios/test_coding_project_flow.py | Adds Playwright E2E scenarios for project chrome + shell mode + persistence + validation. |
| tests/e2e/CLAUDE.md | Registers the new E2E scenario in the test index doc. |
| src/tools/registry.rs | Adds registration for project-admin tools. |
| src/tools/builtin/project_admin.rs | Implements dispatcher-compliant tools for project create/update/set-active/assign-thread. |
| src/tools/builtin/mod.rs | Exposes the new project-admin tools from the builtin module. |
| src/main.rs | Wires ProjectContextCache into the gateway when tool dispatch is enabled. |
| src/channels/web/util.rs | Adds shell-turn reconstruction when building turns from persisted DB messages. |
| src/channels/web/types.rs | Adds ChatSendMode, thread project context DTO, and TurnInfo.shell payload. |
| src/channels/web/tests/multi_tenant.rs | Updates test state init for new project_context_cache field. |
| src/channels/web/test_helpers.rs | Updates test builder state init for new project_context_cache field. |
| src/channels/web/server.rs | Implements /api/chat/send shell-mode handling, adds project hints into message metadata, and includes project context in thread listing/new-thread responses. |
| src/channels/web/platform/ws.rs | Updates platform ws tests for new project_context_cache field. |
| src/channels/web/platform/state.rs | Adds project_context_cache to GatewayState. |
| src/channels/web/platform/router.rs | Adds routes for project create/update/active and thread project assignment. |
| src/channels/web/platform/project_context_cache.rs | Adds TTL cache that refreshes git/gh state via shell-tool dispatch. |
| src/channels/web/platform/mod.rs | Exposes the new project_context_cache module. |
| src/channels/web/mod.rs | Adds builder wiring for project_context_cache into the gateway channel state. |
| src/channels/web/handlers/settings.rs | Updates handler tests for new project_context_cache field. |
| src/channels/web/handlers/engine.rs | Adds dispatcher-routed project mutation handlers and read-only project resolution helpers. |
| src/channels/web/handlers/chat.rs | Adds project context to thread list/new-thread responses. |
| src/bridge/router.rs | Extends EngineProjectInfo with workspace_path + typed metadata view; adds create/update project bridge funcs and conversation project binding. |
| src/bridge/mod.rs | Re-exports new bridge types/functions for project support. |
| src/app.rs | Registers project-admin tools alongside memory tools using the workspace resolver. |
| src/agent/dispatcher.rs | Builds SkillActivationContext from gateway-provided message metadata. |
| src/agent/agent_loop.rs | Plumbs activation context into skill selection (prefilter_skills_with_context). |
| skills/coding-repo/SKILL.md | Adds the new coding-repo skill, gated on having a GitHub-backed project. |
| crates/ironclaw_skills/src/types.rs | Adds activation context criteria and runtime SkillActivationContext. |
| crates/ironclaw_skills/src/selector.rs | Adds context-aware prefiltering and tests for context gating behavior. |
| crates/ironclaw_skills/src/lib.rs | Re-exports new context types and new selector entrypoint. |
| crates/ironclaw_gateway/static/style.css | Adds project chrome + modal styles and shell-mode turn/badge styling. |
| crates/ironclaw_gateway/static/app.js | Implements Project UI (chrome + modal), shell-mode input routing, SSE rendering, and history shell-turn rendering. |
| crates/ironclaw_common/src/lib.rs | Re-exports GitHubRepo newtype and error. |
| crates/ironclaw_common/src/identity.rs | Adds GitHubRepo newtype with validation + tests. |
| crates/ironclaw_common/src/event.rs | Adds shell_command / shell_output SSE event variants. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .run_shell( | ||
| user_id, | ||
| workspace_path, | ||
| "gh pr view --json number,title,url,state", |
There was a problem hiding this comment.
refresh_pr runs gh pr view --json number,title,url,state, but parse_gh_pr relies on a draft flag. As written, the cache can never detect drafts because the command doesn't request the draft field. Include the draft field in the --json list (e.g., isDraft) so draft PRs are represented correctly in PrState.
| "gh pr view --json number,title,url,state", | |
| "gh pr view --json number,title,url,state,isDraft", |
| title: String, | ||
| url: String, | ||
| state: String, | ||
| #[serde(default)] |
There was a problem hiding this comment.
parse_gh_pr deserializes a field named is_draft, but gh's JSON uses camelCase (isDraft). Without a #[serde(rename = "isDraft")] (and requesting that field in the command), row.is_draft will always be false and draft PRs will be misclassified as Open.
| #[serde(default)] | |
| #[serde(default, rename = "isDraft")] |
| /// The default value (no project, no workspace path) admits every | ||
| /// skill — callers with no project context pass it and no gate | ||
| /// triggers. Callers that do know the active project populate the | ||
| /// matching booleans so gated skills activate. |
There was a problem hiding this comment.
The docs claim the default SkillActivationContext "admits every skill", but satisfies(Some("github_repo")) returns false when the default booleans are false, so gated skills are filtered out by default. Please update the doc comment to match the implemented behavior (or adjust satisfies if the intended contract is truly 'admit by default').
| /// The default value (no project, no workspace path) admits every | |
| /// skill — callers with no project context pass it and no gate | |
| /// triggers. Callers that do know the active project populate the | |
| /// matching booleans so gated skills activate. | |
| /// The default value (no project, no workspace path) admits ungated | |
| /// skills, but does not satisfy recognised project-field gates such as | |
| /// `"github_repo"` or `"workspace_path"`. Unknown gate names still | |
| /// admit by default for forward compatibility. Callers that do know | |
| /// the active project populate the matching booleans so gated skills | |
| /// activate. |
|
|
||
| /// Create a new project owned by `user_id` with the supplied fields. | ||
| /// | ||
| /// `name` and `description` are required (via `fields.name` / `fields.description`). |
There was a problem hiding this comment.
This doc comment says name and description are required, but the implementation only requires name (description defaults via unwrap_or_default()). Please align the documentation with the actual behavior so callers know which fields are mandatory.
| /// `name` and `description` are required (via `fields.name` / `fields.description`). | |
| /// `name` is required (via `fields.name`). `description` is optional and | |
| /// defaults to an empty string when omitted. |
| fn spawn_stale_refreshes( | ||
| self: &Arc<Self>, | ||
| project_id: ProjectId, | ||
| workspace_path: &Path, | ||
| user_id: &str, | ||
| snapshot: &CachedContext, | ||
| ) { | ||
| let now = Instant::now(); | ||
| let workspace_path = workspace_path.to_path_buf(); | ||
|
|
||
| if is_stale(snapshot.branch_fetched_at, BRANCH_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path.clone(); | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_branch(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| if is_stale(snapshot.dirty_fetched_at, DIRTY_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path.clone(); | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_dirty(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| if is_stale(snapshot.pr_fetched_at, PR_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path; | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_pr(project_id, &wp, &user).await; | ||
| }); | ||
| } |
There was a problem hiding this comment.
get() clones the current snapshot and spawn_stale_refreshes unconditionally tokio::spawns refresh tasks when fields are stale/missing. If multiple requests hit /api/chat/threads while entries are stale (or missing), this can fan out into many concurrent git/gh shell dispatches for the same project. Consider adding an in-flight guard (per project + field), or pessimistically updating the *_fetched_at timestamp before spawning, so repeated reads don't create a thundering herd of refresh jobs.
| let parsed: serde_json::Value = | ||
| serde_json::from_str(&out.content).unwrap_or(serde_json::Value::Null); | ||
| ( | ||
| parsed | ||
| .get("stdout") | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or("") | ||
| .to_string(), | ||
| parsed | ||
| .get("stderr") | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or("") | ||
| .to_string(), | ||
| parsed | ||
| .get("exit_code") | ||
| .and_then(|v| v.as_i64()) | ||
| .unwrap_or(0) as i32, | ||
| parsed | ||
| .get("truncated") | ||
| .and_then(|v| v.as_bool()) | ||
| .unwrap_or(false), |
There was a problem hiding this comment.
shell_output rows are parsed assuming the content is a JSON object; if the persisted content is a JSON string (or invalid JSON), serde_json::from_str(...).unwrap_or(Value::Null) causes stdout/stderr to be dropped and exit_code to default to 0, which incorrectly marks the turn as Completed. Consider parsing the shell_output content with the same defensive logic as parse_shell_tool_output (object vs string), and use a non-zero/unknown exit code when parsing fails so the UI doesn't render a false success badge.
| let parsed: serde_json::Value = | |
| serde_json::from_str(&out.content).unwrap_or(serde_json::Value::Null); | |
| ( | |
| parsed | |
| .get("stdout") | |
| .and_then(|v| v.as_str()) | |
| .unwrap_or("") | |
| .to_string(), | |
| parsed | |
| .get("stderr") | |
| .and_then(|v| v.as_str()) | |
| .unwrap_or("") | |
| .to_string(), | |
| parsed | |
| .get("exit_code") | |
| .and_then(|v| v.as_i64()) | |
| .unwrap_or(0) as i32, | |
| parsed | |
| .get("truncated") | |
| .and_then(|v| v.as_bool()) | |
| .unwrap_or(false), | |
| let (stdout, stderr, exit_code, truncated) = | |
| match serde_json::from_str::<serde_json::Value>(&out.content) { | |
| Ok(serde_json::Value::Object(parsed)) => ( | |
| parsed | |
| .get("stdout") | |
| .and_then(|v| v.as_str()) | |
| .unwrap_or("") | |
| .to_string(), | |
| parsed | |
| .get("stderr") | |
| .and_then(|v| v.as_str()) | |
| .unwrap_or("") | |
| .to_string(), | |
| parsed | |
| .get("exit_code") | |
| .and_then(|v| v.as_i64()) | |
| .map(|v| v as i32) | |
| .unwrap_or(1), | |
| parsed | |
| .get("truncated") | |
| .and_then(|v| v.as_bool()) | |
| .unwrap_or(false), | |
| ), | |
| Ok(serde_json::Value::String(stdout)) => (stdout, String::new(), 1, false), | |
| Ok(other) => (other.to_string(), String::new(), 1, false), | |
| Err(_) => (out.content.clone(), String::new(), 1, false), | |
| }; | |
| ( | |
| stdout, | |
| stderr, | |
| exit_code, | |
| truncated, |
There was a problem hiding this comment.
Code Review
This pull request implements coding project management and a direct shell execution mode (via the ! prefix) in the gateway, introducing a validated GitHubRepo type, a TTL-bound ProjectContextCache for git status, and new administration tools. The review identifies several performance and architectural concerns, most notably the O(N) I/O-bound project resolution within thread list loops and inefficient frontend API usage for thread refreshes. Additionally, the feedback highlights a thundering herd risk in the cache refresh logic and the use of blocking synchronous filesystem operations within asynchronous functions, which could stall the runtime.
| let project = crate::channels::web::handlers::engine::thread_project_context( | ||
| state.as_ref(), | ||
| &identity.user_id, | ||
| s.id, | ||
| ) | ||
| .await; |
There was a problem hiding this comment.
This loop performs O(N) project resolution for every thread in the list. Each call to thread_project_context involves multiple I/O-bound operations: a database query for conversation metadata, a workspace file read for the active project pointer, and an engine store lookup. For users with many threads, this will significantly degrade the performance of the thread list endpoint. Since the project chrome is typically only displayed for the active thread, consider resolving the project context lazily or only for the current thread. Please ensure this optimization is justified by profiling data before implementation to avoid premature optimization.
References
- Performance optimizations should be gated on profiling to ensure they address actual bottlenecks.
- Avoid premature optimization; prioritize readability and maintainability for non-critical paths.
| let project = crate::channels::web::handlers::engine::thread_project_context( | ||
| state.as_ref(), | ||
| &user.user_id, | ||
| s.id, | ||
| ) | ||
| .await; |
There was a problem hiding this comment.
Similar to the handler in chat.rs, this loop performs O(N) project resolution for every thread summary. This is highly inefficient as it triggers database queries and workspace reads for every thread in the list. This data is only used by the frontend for the currently active thread. Ensure this optimization is gated on profiling to confirm it is a performance bottleneck.
References
- Performance optimizations should be gated on profiling to ensure they address actual bottlenecks.
- Avoid premature optimization; prioritize readability and maintainability for non-critical paths.
| apiFetch('/api/chat/threads') | ||
| .then((data) => { | ||
| const all = [] | ||
| .concat(data.assistant_thread ? [data.assistant_thread] : []) | ||
| .concat(data.threads || []); | ||
| const match = all.find((th) => th && th.id === currentThreadId); | ||
| refreshChromeFromThread((match && match.project) || null); | ||
| }) | ||
| .catch(() => { /* chrome stays in its last state */ }); |
There was a problem hiding this comment.
refreshCurrentThread fetches the entire thread list via /api/chat/threads just to find the project info for the current thread. This is inefficient, especially as the backend implementation of this endpoint is now significantly more expensive due to per-thread project resolution. Consider adding a dedicated endpoint for single-thread info or including the project context in the history response to avoid this O(N) frontend lookup.
| if is_stale(snapshot.branch_fetched_at, BRANCH_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path.clone(); | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_branch(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| if is_stale(snapshot.dirty_fetched_at, DIRTY_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path.clone(); | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_dirty(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| if is_stale(snapshot.pr_fetched_at, PR_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path; | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_pr(project_id, &wp, &user).await; | ||
| }); | ||
| } |
There was a problem hiding this comment.
The spawn_stale_refreshes method lacks in-flight tracking. If multiple requests hit the cache for the same project simultaneously, it will spawn numerous redundant background tasks, each executing shell commands. Consider using a mechanism to track and deduplicate active refresh tasks for the same project ID to prevent a thundering herd problem. Note that adding such a caching layer or optimization should be gated on profiling per repository guidelines.
References
- Performance optimizations, such as adding a caching layer, should be gated on profiling.
| if let Err(e) = crate::bridge::sandbox::workspace_path::default_project_workspace_path( | ||
| &user.user_id, | ||
| project_id, | ||
| ) | ||
| .parent() | ||
| .map(std::fs::create_dir_all) | ||
| .unwrap_or(Ok(())) | ||
| { | ||
| tracing::debug!(error = %e, "ensure parent projects dir (non-fatal)"); | ||
| } | ||
| if !workspace_path.exists() | ||
| && let Err(e) = std::fs::create_dir_all(&workspace_path) | ||
| { | ||
| tracing::warn!(path = %workspace_path.display(), error = %e, "failed to create project workspace dir"); | ||
| } |
There was a problem hiding this comment.
…d bounce, copy
Four follow-ups on the coding-agent UX PR:
1. **Project name consistency** — the auto-created `default` project
already rendered as "General" in the Projects overview but showed
as "default" in the new chrome bar + manage modal. Add a single
`displayProjectName()` helper and apply it in both surfaces. The
backend name stays `default` (stable lookup key in
`bridge/router.rs`); the edit form locks the name field for that
row so accidental renames can't break the backend resolver.
2. **Assistant spans all projects** — the pinned Assistant
conversation is the home thread, but the earlier PR resolved an
active project for it, so the chrome bar + folder chip lit up
whenever the user selected Assistant. Skip
`thread_project_context` when `s.id == assistant_id` in both
`handlers/chat.rs` and `server.rs`; the Assistant row now stays
project-less across the whole UI.
3. **Reload bounce on tab-refocus** — `SSE_RELOAD_THRESHOLD_MS` is
10s, so any tab switch longer than that triggers a full
`loadHistory()`. The existing code cleared the DOM to a skeleton
before repainting and every message replayed its `slideUp`
keyframe, producing a visible cascade on every refocus. Now skip
the skeleton when there's already rendered content and stamp a
`.history-replay` class on the chat container so the CSS
suppresses per-message slide-in animations. `.finally` clears the
flag so fresh streaming turns still animate.
4. **Copy button falls back to execCommand** — the clipboard API
rejects silently in some environments (insecure origins,
Firefox's stricter focus handling, Chrome extension sandboxes).
Add an `execCommand('copy')` retry path with a hidden textarea,
log failures to console, and strip the copy button's own label
from the textContent fallback so "Copy" doesn't leak into the
clipboard when the primary path fails.
Verified
- `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` clean
- `scripts/pre-commit-safety.sh` clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings staging's gateway modularization (ironclaw#2599 stages 4c + 4d + 5) and #2677 UserId collapse into the coding-agent UX branch: Conflicts resolved: - crates/ironclaw_common/src/lib.rs — combined GitHubRepo export with the new JobResultStatus export. - src/channels/web/types.rs — kept both the staging `attachments` field and the branch's `ChatSendMode`/`mode` field on SendMessageRequest. - src/channels/web/server.rs — accepted staging's thin shim; my chat handler deltas reapplied in the canonical `features/chat/mod.rs`. - src/channels/web/handlers/chat.rs — deleted per staging (content moved into `features/chat/mod.rs`); my changes reapplied there. - crates/ironclaw_gateway/static/app.js + style.css — deleted per staging (split into per-surface modules). ProjectUI IIFE migrated to `js/surfaces/projects.js`; `!` prefix detection added in `js/surfaces/chat.js::sendMessage`; shell SSE handlers in `js/core/sse.js`; shell turn + history-replay class in `js/core/history.js`; `copyMessage` fallback in `js/core/render.js`. Project chrome + shell-turn CSS appended to `styles/surfaces/projects.css`; `history-replay` suppression added in `styles/surfaces/chat.css`. types.md applied to the coding-agent surface: - `bridge::update_engine_project` now takes `ironclaw_engine::ProjectId` rather than `&str` — internal calls flow the typed id, eliminating the class of bug types.md prevents. - `bridge::set_conversation_project` takes `Option<ProjectId>` for the same reason. - `project_admin` tools parse raw UUIDs into `ProjectId` at the tool boundary via the new `parse_project_id` helper, so every downstream call site is typed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 66 out of 211 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Mod+1-5: switch tabs | ||
| if (mod && e.key >= '1' && e.key <= '5') { | ||
| e.preventDefault(); | ||
| const tabs = engineV2 |
There was a problem hiding this comment.
engineV2 is not defined in the shared bootstrap state (the global flag elsewhere is engineV2Enabled). As written, this will throw a ReferenceError on the first Mod+1..5 keypress (or always take the wrong branch if a different global happens to exist). Use the existing global (engineV2Enabled) consistently in this shortcut handler.
| const tabs = engineV2 | |
| const tabs = engineV2Enabled |
| apiFetch('/api/gateway/status').then(function(data) { | ||
| activeWorkStore.setEngineV2Enabled(!!data.engine_v2); | ||
| applyEngineModeUi(); | ||
| refreshPersistentActivityBar(); | ||
|
|
||
| // Update restart button visibility | ||
| restartEnabled = data.restart_enabled || false; | ||
| updateRestartButtonVisibility(); | ||
|
|
||
| // Apply engine v2 / v1 tab visibility once. | ||
| if (!engineModeApplied) { | ||
| engineV2Enabled = !!data.engine_v2_enabled; | ||
| applyEngineModeToTabs(); | ||
| engineModeApplied = true; | ||
| } |
There was a problem hiding this comment.
The status payload fields used to enable engine-v2 UI are inconsistent (data.engine_v2 vs data.engine_v2_enabled). This can cause engineV2Enabled (and therefore tab visibility/activity strip behavior) to flip incorrectly depending on which field is actually present. Pick a single canonical field name from the API response and use it consistently for both activeWorkStore.setEngineV2Enabled(...) and engineV2Enabled = ....
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[serde(rename_all = "snake_case")] | ||
| pub enum JobResultStatus { | ||
| Completed, | ||
| Failed, | ||
| Cancelled, | ||
| /// Worker timeout / stuck-state path. Emitted when a job's context | ||
| /// is transitioned to `JobState::Stuck` (see `worker/job.rs` | ||
| /// `mark_stuck`). Distinct from `Failed` so the UI and analytics | ||
| /// can surface recovery-eligible runs separately from hard errors. | ||
| Stuck, | ||
| } |
There was a problem hiding this comment.
The doc/comments (and FromStr) say legacy "error" should be accepted as an alias for Failed, but serde deserialization for JobResultStatus will currently reject "error" on the wire. If older producers still emit "error", AppEvent::JobResult deserialization will fail. Fix by adding a serde alias on the Failed variant (e.g., #[serde(alias = "error")]), or by implementing a custom Deserialize that maps "error" → Failed.
| } | ||
|
|
||
| // Wire up Enter key on search input | ||
| document.getElementById('skill-search-input').addEventListener('keydown', function(e) { |
There was a problem hiding this comment.
This unguarded getElementById(...).addEventListener(...) will throw at script-evaluation time if #skill-search-input is not present in the DOM (notably plausible given the new layout-level tab hiding and the fact that all split JS modules are concatenated and loaded globally). Use optional chaining (?.addEventListener) or a null check like other surfaces in this PR.
| document.getElementById('skill-search-input').addEventListener('keydown', function(e) { | |
| document.getElementById('skill-search-input')?.addEventListener('keydown', function(e) { |
| } | ||
|
|
||
| function createTokenForUser(userId, displayName) { | ||
| var tokenName = prompt('Token name for ' + displayName + ':', 'api-token'); |
There was a problem hiding this comment.
This introduces a hard-coded English prompt string in an otherwise i18n-driven UI (the file already uses I18n.t(...) extensively). To keep localization and consistency, move both the prompt text and default value to i18n keys (and keep displayName as an interpolated param).
| var tokenName = prompt('Token name for ' + displayName + ':', 'api-token'); | |
| var tokenName = prompt( | |
| I18n.t('users.tokenNamePrompt', { displayName: displayName }), | |
| I18n.t('users.defaultTokenName') | |
| ); |
…I `!` mode Gateway - `core/history.js::switchThread` / `switchToAssistant` / `createNewThread` now dispatch a `threadchange` `CustomEvent` on `window`. `surfaces/projects.js` listens for it and refreshes the project chrome. The old hook targeted a `window.switchToThread` that never existed (it was always file-scoped via bundle concatenation) so chrome stayed stale after every thread switch — including right after the user set an active project in the manage modal. - `handlers/engine::resolve_thread_project` falls back to the user's auto-created `default` project when no workspace active-pointer is set. Fresh users now get `!ls` working on day one instead of a silent 409 from `ChatSendMode::Shell` due to "no project". TUI (`ironclaw_tui` + `channels/tui`) - New `AppState::shell_mode` flag + `TuiUserMessage::shell_mode` field. - Typing `!` at the start of the input flips the prompt glyph from `›` to `!` (warning-yellow for visual clarity); removing the prefix flips it back. Toggling is driven by `update_input_overlays_from_input`. - Esc in shell mode clears the buffer and cancels the mode instead of routing to the agent-level `/interrupt`. Esc with no shell mode behaves unchanged. - Submit in shell mode strips the `!`, sends the bare command with `shell_mode: true`, and records `!cmd` in input history + the on-screen user turn so shell turns are visually distinct. - `channels/tui::build_tui_incoming_message` propagates the flag into `IncomingMessage.metadata.shell_mode`. - `Agent::handle_message` intercepts `metadata.shell_mode == true`, runs the `shell` tool via the existing `ToolRegistry`, and returns a preformatted `$ cmd` / stdout / stderr / exit-code block as a synthetic `HandleOutcome::Respond`. The normal agentic pipeline (LLM + tool loop) is bypassed entirely — shell mode is an explicit user action, not an agent decision. Tests - `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` clean - `cargo test -p ironclaw_tui --lib` — 173 passing (new shell-mode state field round-trips through existing tests) - `cargo test -p ironclaw --lib channels::web` — 442 passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synthesizes recurring patterns from ~30 merged PRs, 147 bot review comments (Copilot/Gemini), human reviews, and ~50 issues filed in the past 2 weeks. Each rule cites the motivating PR/issue numbers. New files: - error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?, poisoned caches), persist-then-reload atomicity, channel-edge error mapping. (#2526, #2633, #2653, #2673, #2546, #2407, #2408) - agent-evidence.md — side-effect claims must cite tool evidence, empty-fast outputs are errors, external-effect tools must read back, setup UI round-trip. (#2544, #2580, #2582, #2541, #2545, #2411, #2543, #2586) - lifecycle.md — discovery vs. activation, terminal auth rejection, list_installed vs. list_active, deactivation unwinds, snapshot rehydrate must re-validate. (#2556, #2557, #2558, #2564, #2419, PR #2617, PR #2631) Extended: - types.md — from_trusted boundary rule, validated-newtype template with shared validate(&str), serde(try_from) required for validated types, wire-stable enums (no Debug; serde alias for migrations), canonical wire-contract field naming. (PR #2685, #2681, #2687, #2678, #2669, #2665, #2683, #2702) - safety-and-sandbox.md — every new ingress scans pre-transform/pre- injection, bounded resources (interners/streams/fan-out caps), cache keys must be complete. (#2491, #2676, #2470, #2633, #2673, #2710, PR #2702) - review-discipline.md — PR scope discipline, guardrail scripts are code (regression tests, grouped-import parsing, CI has_code inclusion), absolute-path ban in committed docs, stale comments after refactors. (PR #2668, #2628, #2680, #2687, #2647, #2689, #2701) All new files carry paths: frontmatter so they auto-load only on matching files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| // the tool output never comes back. | ||
| if let Some(ref store) = state.store // dispatch-exempt: shell-mode persistence pair | ||
| && let Err(e) = store | ||
| .add_conversation_message(thread_id, "shell_command", &format!("!{command}")) |
There was a problem hiding this comment.
High Severity
This path accepts an arbitrary thread_id and then writes directly to that conversation without first checking conversation_belongs_to_user(...). Because resolve_thread_project() falls back to the caller's active/default project when it can't resolve thread-owned metadata, a user who guesses another conversation UUID can append shell_command / shell_output rows to someone else's thread.
The same root issue exists on project_assign_thread: please add a shared ownership guard before any thread-scoped mutation (add_conversation_message, set_conversation_project, etc.), and cover it with a multi-tenant caller-level test.
| // shape defensively — the cache should not panic on a shape drift. | ||
| if let Some(obj) = value.as_object() { | ||
| let stdout = obj | ||
| .get("stdout") |
There was a problem hiding this comment.
High Severity
ToolDispatcher::dispatch("shell", ...) returns the shell tool's current wire shape {output, exit_code, ...}, but this cache still reads stdout. That leaves res.stdout empty on success, which in turn makes branch refreshes come back as None and dirty checks look clean even when the repo is dirty.
Please parse output here (matching parse_shell_tool_output() in the chat handler) and add a test against the real shell-tool payload shape so the cache and tool contract stay in sync.
| # Select and inject skills based on goal keywords | ||
| all_skills = __list_skills__() | ||
| explicit_skills, _rewritten_goal, missing_explicit_skills = extract_explicit_skills(all_skills, goal) | ||
| active_skills = select_skills(all_skills, goal, max_candidates=3, max_tokens=6000) |
There was a problem hiding this comment.
High Severity
This still keyword-scores every skill returned by __list_skills__() with no equivalent of v1's prefilter_skills_with_context(...). As a result, a skill like coding-repo can still activate on engine v2 threads that have no github_repo project field set, even though the new manifest contract says it should be gated out.
Please port the activation.context.require_project_field filtering into the v2 selector before scoring, otherwise v1 and v2 will diverge on exactly the project-scoping rule this PR is introducing.
| } | ||
| const fileInput = document.getElementById('image-file-input'); | ||
| if (fileInput) { | ||
| fileInput.addEventListener('change', (e) => { |
There was a problem hiding this comment.
High Severity
This file now binds two change listeners to the same #image-file-input: the older one above calls handleImageFiles(...), while this one calls handleAttachmentFiles(...). For an image selection/paste, the same file gets staged into both stagedImages and stagedAttachments, and sendMessage() later submits both body.images and body.attachments.
That silently double-sends the same image to the backend/model. Please collapse the upload path to a single staging pipeline and add a regression test that verifies one picked image produces exactly one uploaded attachment payload.
* docs(rules): add review-driven guidance for Claude Code Synthesizes recurring patterns from ~30 merged PRs, 147 bot review comments (Copilot/Gemini), human reviews, and ~50 issues filed in the past 2 weeks. Each rule cites the motivating PR/issue numbers. New files: - error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?, poisoned caches), persist-then-reload atomicity, channel-edge error mapping. (#2526, #2633, #2653, #2673, #2546, #2407, #2408) - agent-evidence.md — side-effect claims must cite tool evidence, empty-fast outputs are errors, external-effect tools must read back, setup UI round-trip. (#2544, #2580, #2582, #2541, #2545, #2411, #2543, #2586) - lifecycle.md — discovery vs. activation, terminal auth rejection, list_installed vs. list_active, deactivation unwinds, snapshot rehydrate must re-validate. (#2556, #2557, #2558, #2564, #2419, PR #2617, PR #2631) Extended: - types.md — from_trusted boundary rule, validated-newtype template with shared validate(&str), serde(try_from) required for validated types, wire-stable enums (no Debug; serde alias for migrations), canonical wire-contract field naming. (PR #2685, #2681, #2687, #2678, #2669, #2665, #2683, #2702) - safety-and-sandbox.md — every new ingress scans pre-transform/pre- injection, bounded resources (interners/streams/fan-out caps), cache keys must be complete. (#2491, #2676, #2470, #2633, #2673, #2710, PR #2702) - review-discipline.md — PR scope discipline, guardrail scripts are code (regression tests, grouped-import parsing, CI has_code inclusion), absolute-path ban in committed docs, stale comments after refactors. (PR #2668, #2628, #2680, #2687, #2647, #2689, #2701) All new files carry paths: frontmatter so they auto-load only on matching files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(rules): split agent-evidence into prompt + code rule agent-evidence.md mixed two concerns: runtime agent instruction (what the LLM should do when concluding a turn) and code-enforcement rules (what the dispatcher, engine, and tools must implement). Rules under .claude/rules/ only guide Claude Code when editing the repo — the runtime agent never reads them. Splits the two: - crates/ironclaw_engine/prompts/codeact_postamble.md — new section "Evidence before claiming side effects". Sits next to the existing "FINAL() answer quality" guidance; loaded via include_str! in executor/prompt.rs (no Rust change needed). - .claude/rules/tool-evidence.md — renamed from agent-evidence.md, keeps only the code invariants (engine v2 side-effect gate, empty-fast ToolError::EmptyResult, external-effect tools must read back, setup UI round-trip). Prompt tests pass unchanged; the postamble addition is pure text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * prompt: tighten evidence rule to FINAL() claims only, not tool use Live-test validation of the "Evidence before claiming side effects" section (added in the prior commit) showed it inhibited legitimate tool use. With the original wording, `zizmor_scan_v2` live-recording timed out at 302s with zero responses; reverting the postamble restored healthy behavior (88s run, 8 shell calls including `cargo install zizmor` and full workflow analysis). The original phrasing conflated two things: what the agent should claim and what tools it should call. The rule is only about the claim. Re-tunes the section to: - Open with an explicit "this does not restrict tool calls" scope. - Drop the "<1ms = failure" heuristic (too broad — normal tools like `tool_info(schema)` are legitimately fast). - Drop the full enumeration of forbidden side-effect verbs; keep the rule narrower and clearer. - Shorten the code example (remove redundant early-return). Re-tuned run: agent is active (shell calls, real reasoning), live recording completes in ~9s. The remaining test failure is a pre-existing assertion bug (exact `t == "shell"` match against tool strings that now carry arguments like `"shell(cmd)"`) — reproduces with the old postamble too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(live): fix tool-name assertions + re-record zizmor traces The two `zizmor_scan*` live tests had four broken tool-name assertions that silently failed to match: `tools.iter().any(|t| t == "shell")` against a tool list that now contains `"shell(cmd)"` strings (tool events carry args via `format_action_display_name` in `src/bridge/router.rs`). Two of the four were negative assertions checking for the absence of `tool_install` recovery loops — those silently passed even when a recovery loop actually ran. `sandbox_live_e2e.rs:203` already used the correct `t == "shell" || t.starts_with("shell(")` pattern; applied it consistently to all four sites. Verified live: - `IRONCLAW_LIVE_TEST=1 cargo test --test e2e_live -- zizmor_scan --ignored --test-threads=1` → 2 passed, 0 failed, 51.78s. Agent installs and runs zizmor end-to-end, producing real findings (exit code 14, dangerous triggers, excessive permissions, etc.). Traces re-recorded with the tuned postamble (commit 50d8517) and scrubbed: replaced `/home/illia/.cargo/bin/zizmor` with `/home/user/.cargo/bin/zizmor` per the developer-local-path ban in `.claude/rules/review-discipline.md`. No credentials, PII, or high-entropy secrets in either trace (only git SHAs from zizmor's workflow analysis output). Replay still passes: `cargo test --test e2e_live -- zizmor_scan --ignored` → 2/2 ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(replay): update zizmor_scan_v2 insta snapshot The engine v2 replay-snapshot gate (`engine_v2_tests::snapshot_zizmor_scan_v2`) failed against the re-recorded trace from 1691efe because the old snapshot encoded a broken run: - final_state: Failed - Missing Assistant message role - 3 issues: thread_failure (error), no_response (warning), llm_error (error) - 6 tool calls that never produced a final answer The new trace completes cleanly: - final_state: Done - System / User / Assistant roles present - 1 issue: mixed_mode (info) - 3 shell tool calls + successful `FINAL()` with real findings The snapshot was pinning a regression. Regenerated with `INSTA_UPDATE=always cargo test --test e2e_engine_v2 -- snapshot_zizmor_scan_v2`; passes on replay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(rules): address PR #2714 review feedback - review-discipline: reword "Doc Absolute Paths" as a review convention (pre-commit only scans .rs; the rule misleadingly claimed enforcement). - safety-and-sandbox: broaden `paths:` frontmatter to include the actual ingress owners (`bridge`, `channels`, `workspace`, `agent`, engine crate) so the rule auto-loads where it applies. - tool-evidence: mark the side-effect gate, empty-fast rule, and `unverified` flag as target/aspirational invariants — neither `ToolError::EmptyResult`, an `unverified` field on `ToolOutput`, nor a byte-count field on `ActionRecord` exist today. Point at concrete interim conventions (`ToolError::ExecutionFailed`, `unverified: true` in the JSON result body). - types: scope "Validated newtypes must gate Deserialize" to *new* types, document the `CredentialName`/`ExtensionName` exception (they intentionally use `#[serde(transparent)]` + derived `Deserialize` under the `serde_does_not_revalidate` test). Clarify the `from_trusted` trust boundary (trusted = typed upstream, untrusted = raw JSON field even if the field *name* is "registry entry"). Switch `new` template to `impl Into<String>` to avoid an unnecessary clone when an owned `String` is passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(rules): simplify types.md + split doc-hygiene; address review round 2 - types: collapse two templates into one canonical validated-newtype shape. New types use `#[serde(try_from = "String")]` with a shared `validate(&str)` helper — no more dual "transparent for some / try_from for others" guidance. `CredentialName`/`ExtensionName` are documented as the sole legacy exception (locked in by the `serde_does_not_revalidate` test); new code must not copy their `transparent` + `from_trusted` pattern. Removes the long "Using `from_trusted` safely" section and the separate "Validated newtypes must gate Deserialize" subsection that contradicted the Don'ts list. - doc-hygiene: new tiny rule file scoped to `**/*.md`, `**/*.py`, `docs/**` that carries the "no developer-local absolute paths in committed docs" convention. Removed from review-discipline.md where its `src/**/*.rs` scope meant the rule never loaded on the files it governed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(live): match hyphenated tool-install in attempted_relevant_tool The engine records `action_name` as the raw string the LLM emitted (`crates/ironclaw_engine/src/executor/structured.rs:381`), and the registry's lookup canonicalization only affects dispatch — not the name that reaches `StatusUpdate::ToolStarted`. The two other predicates in this file (`bad_recovery` at :420, `phase_b_recovery` at :531) already defend against both forms; this one should too, for consistency. Addresses PR #2714 review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…issue skill
Extends the coding-agent UX with per-thread git worktrees, a
`thread_metadata_set` builtin that skills can use to stash per-thread
state, and a `/fix-issue` skill that drives issue-to-PR flows
end-to-end. Adds a live e2e test that exercises the full plumbing
against a real scratch repo.
Engine
- `thread_metadata_set` builtin tool (src/tools/builtin/thread_metadata.rs):
validates a JSON `patch`, echoes it as its output payload, caps at
8 KiB. The orchestrator reads the output post-execution and applies a
replace-at-top-level-key merge to `thread.metadata` in the single and
parallel action paths.
- Orchestrator injects a compact `thread_state: {...}` section into the
leading system message before each LLM call so the model always sees
current metadata and can patch it. Merges into the existing system
message instead of appending a trailing one — NearAI/Qwen rejects
"system message after non-system" otherwise.
- `ThreadExecutionContext.thread_metadata` passes a snapshot of the
thread's metadata through the effect boundary, so host adapters can
read per-thread state without a store round-trip.
- `FilesystemBackend::shell` now spawns `/bin/sh -c` with `cwd` set to
the mount root (plus optional relative subdir). Was `Unsupported`
before; the intercept fell through to host execution, which broke
`/project/` routing for sandbox-free runs.
Bridge / sandbox
- `thread_worktree_subdir` extracts `thread.metadata.dev.worktree`
(rejecting `..`/absolute paths) and `rewrite_path_for_worktree`
splices that subdir into `/project/...` paths so per-thread worktrees
are transparent to the agent. Explicit `/project/worktrees/...` paths
stay unchanged (escape hatch).
- `effect_adapter` threads the subdir into `maybe_intercept`.
- `get_engine_thread_metadata` is a small bridge helper for chrome
rendering.
Skills
- `skills/fix-issue/SKILL.md` — 8-step flow: parse issue ref → fetch via
github http skill → clone + worktree → research → implement → test →
commit + push → open draft PR. Directs the agent to keep
`thread.metadata.dev` current after every milestone.
- `skills/coding-repo/SKILL.md` v0.2.0 — rewrites the body around
per-thread worktrees and `thread_metadata_set` milestones; swaps the
`gh` CLI examples for http against `api.github.com`; drops `gh` from
`requires.bins`.
Gateway UI
- New chrome pills: `repo`, `branch`, `#issue`, `PR #n` — conditional on
`project.metadata.kind === "dev"` signal and backed by
`thread.metadata.dev.*`. Styles in projects.css.
- `ThreadProjectContext` gains `issue: Option<ThreadIssueContext>`
sourced from thread metadata; `branch` / `pr` overrides prefer
skill-written values over the git-polled cache when set.
- `chat.js` dispatches a synthetic `input` event after shell-mode
submit so the shell-mode CSS class clears; `agent_loop.rs` wraps
shell output in a fenced code block so the TUI renders newlines.
Live e2e test
- `tests/e2e_live_fix_issue_flow.rs` drives `/fix-issue` against the
scratch repo `nearai/ironclaw-e2e-test` (issues enabled, issue #1
seeded). Hard-asserts the plumbing (skill activation, issue fetch,
worktree creation, file write, `thread_metadata_set`); treats
`git push` and `POST /pulls` as soft signals while the LLM
consistency-in-loop work continues.
- `prepare_dev_project` pre-seeds the bootstrap project with
`workspace_path=<tempdir>` + `github_repo` + `default_branch=staging`
so `/project/` routes correctly without Docker/sandbox.
- Accepts `GITHUB_TOKEN` env var (e.g. `$(gh auth token)`) as a
plaintext seed, bypassing master-key drift between `~/.ironclaw/.env`
and the developer's encrypted secrets.
- `docs/e2e-test-fixture-issue.md` captures the exact issue body to
seed on the fixture repo.
Tests
- `cargo test --lib tools::builtin::thread_metadata` — 4 passing.
- `cargo test --lib bridge::sandbox` — 41 passing, including 7 new
worktree-rewrite and metadata-extractor tests.
- `cargo test -p ironclaw_engine --lib workspace::filesystem` — 12
passing, including 3 new `FilesystemBackend::shell` tests.
- `cargo test -p ironclaw_engine --lib` — 441 passing.
- Full `cargo clippy --all --benches --tests --examples --all-features
-- -D warnings` — zero warnings.
- `IRONCLAW_LIVE_TEST=1 GITHUB_TOKEN=$(gh auth token) \
cargo test --features libsql --test e2e_live_fix_issue_flow \
-- --ignored --test-threads=1 --nocapture` — 1 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # crates/ironclaw_common/src/event.rs # src/channels/web/features/chat/mod.rs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 69 out of 69 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn parse_shell_output(value: &serde_json::Value) -> Option<ShellResult> { | ||
| // The shell tool returns a structured JSON object with stdout/stderr/ | ||
| // exit_code; older builds may have returned a string. Accept either | ||
| // shape defensively — the cache should not panic on a shape drift. | ||
| if let Some(obj) = value.as_object() { | ||
| let stdout = obj | ||
| .get("stdout") | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or("") | ||
| .to_string(); | ||
| let exit_code = obj.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0) as i32; | ||
| return Some(ShellResult { stdout, exit_code }); |
There was a problem hiding this comment.
parse_shell_output is reading stdout, but the shell tool returns { output, exit_code, … } (see src/tools/builtin/shell.rs). This makes branch/dirty/PR always compute from an empty string. Parse output (or support both output and stdout for compatibility).
| if is_stale(snapshot.branch_fetched_at, BRANCH_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path.clone(); | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_branch(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| if is_stale(snapshot.dirty_fetched_at, DIRTY_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path.clone(); | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_dirty(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| if is_stale(snapshot.pr_fetched_at, PR_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path; | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_pr(project_id, &wp, &user).await; | ||
| }); | ||
| } |
There was a problem hiding this comment.
spawn_stale_refreshes spawns a new task on every call when a field is stale, with no in-flight dedupe per (project_id, field). Under /api/chat/threads polling this can create a thundering herd of git/gh calls. Consider tracking per-field refresh-in-progress (or last_refresh_started_at) to avoid concurrent duplicates.
| // 3. No per-thread override and no active pointer. Fall back to | ||
| // the user's shared "default" project (rendered as "General" in | ||
| // the UI) — this is the per-user bucket the engine auto-creates | ||
| // on first use, so fresh users without any `project_set_active` | ||
| // call still get a valid workspace for `!`-mode shell dispatches | ||
| // and chrome rendering. Without this fallback the very first | ||
| // `!ls` after login returned 409 and the gateway showed no | ||
| // output, which is what the user hit. | ||
| let projects = crate::bridge::list_engine_projects(user_id).await.ok()?; | ||
| let default_project = projects.into_iter().find(|p| p.name == "default")?; | ||
| Some((default_project, false)) |
There was a problem hiding this comment.
The doc comment for resolve_thread_project says precedence ends with None when there’s no override and no active pointer, but the implementation falls back to a "default" project. This changes the API semantics (and likely prevents the documented 409 "no project" shell-mode path). Either remove the fallback or update the docs/tests and error messaging to match the intended behavior.
| } else { | ||
| (String::new(), String::new(), 0, false, None) | ||
| }; | ||
| turns.push(TurnInfo { | ||
| turn_number, | ||
| user_message_id: Some(msg.id), | ||
| user_input: msg.content.clone(), | ||
| response: None, | ||
| state: if exit_code == 0 { | ||
| "Completed".to_string() | ||
| } else { | ||
| "Failed".to_string() | ||
| }, |
There was a problem hiding this comment.
When a shell_command has no following shell_output, this code sets exit_code to 0 and marks the turn Completed, which is misleading (it’s an incomplete/failed turn). Consider using a sentinel exit_code (e.g. -1) and setting state to Failed/InProgress when the output row is missing.
| //! The fixture repo is `nearai/ironclaw-e2e-test` — a dedicated scratch | ||
| //! repo the team owns so that every run of this test can open and then | ||
| //! close real PRs without polluting `nearai/ironclaw`. The test always | ||
| //! cleans up after itself: on completion it closes the PR and prunes | ||
| //! the branch. |
There was a problem hiding this comment.
This module doc says the test "always cleans up" by closing PRs and pruning branches, but later notes the PR is intentionally left open and requires a manual cleanup script. Please make the doc and behavior consistent (ideally ensure teardown happens automatically, since this test mutates real GitHub state).
| use crate::channels::web::auth::AuthenticatedUser; | ||
| use crate::channels::web::platform::state::GatewayState; | ||
| use crate::channels::web::types::*; | ||
| use crate::tools::builtin::memory::WorkspaceResolver; |
There was a problem hiding this comment.
use crate::tools::builtin::memory::WorkspaceResolver; appears unused in this module (no other references). This will trigger an unused-import warning/error under -D warnings; please remove it or use it explicitly.
| let maybe = crate::bridge::get_engine_project(&pid.0.to_string(), &ctx.user_id) | ||
| .await | ||
| .map_err(map_bridge_err)?; |
There was a problem hiding this comment.
get_engine_project(&pid.0.to_string(), …).await borrows a temporary String across an .await, which won’t compile (temporary doesn’t live long enough). Convert once into a local String (or change the bridge API to accept ProjectId) before awaiting.
| let maybe = crate::bridge::get_engine_project(&pid.0.to_string(), &ctx.user_id) | ||
| .await | ||
| .map_err(map_bridge_err)?; |
There was a problem hiding this comment.
Same temporary-borrow-across-.await issue here: get_engine_project(&pid.0.to_string(), …).await won’t compile. Store pid.0.to_string() in a local variable (or pass a typed ProjectId through the bridge) before awaiting.
Hardens the `apply_patch` tool against the two most common LLM failure
modes observed during the live fix-issue runs, and adds Playwright
coverage for the dev-project chrome pills so the widget surface has an
in-CI regression gate.
apply_patch
- **Closest-match hint on failure** — when `old_string` doesn't match
(exact or any fuzzy form), the error now quotes the nearest matching
region of the file with line numbers. The agent can compare its
`old_string` against what the file actually contains instead of
retrying blind. Port of claude-code's `meta: {actualOldString}`
pattern (highest-impact single change per their own design).
- **Wrong-argument-shape guard** — when the model packs code or
multi-line content into the `path` parameter (recurring regression:
live run #8 hit this and wasted 3 LLM turns), the tool rejects at
the parameter layer with a concrete diagnostic instead of surfacing
the OS-level "Cannot access file" error. Heuristic: paths are one
line, under 512 bytes; anything else is almost certainly mis-routed.
- **Description update** — added claude-code's "smallest unique
old_string, 2-4 adjacent lines" guidance and the line-number-prefix
stripping reminder. Both were bare-metal causes of the agent
overmatching or copy-pasting `42 |` into its patch.
- New helper `file_edit_guard::find_nearest_context(haystack, needle)`:
picks the longest-but-rare line of `needle`, finds it in `haystack`,
widens to a few neighbouring lines, and line-numbers the result.
Tests: `tools::builtin::file::tests::test_apply_patch_not_found_includes_nearest_context`
and `test_apply_patch_rejects_content_in_path_arg`. All 58
`tools::builtin::file*` tests pass.
Playwright / e2e
- New scenarios in `test_coding_project_flow.py`:
- `test_dev_project_pills_render` — seeds a dev project, drives
`window.ProjectUI.refreshChromeFromThread({...})` with a full
`ThreadProjectContext` payload (github_repo, branch, issue, pr),
and asserts each of the four pills (repo, branch, #issue, PR#n)
has the expected text, href, and hover title.
- `test_dev_project_pills_hide_when_fields_absent` — the hide-on-null
branch; ensures the chrome doesn't paint stale pills when the
thread transitions out of a dev-workflow.
- `refreshChromeFromThread` promoted to `window.ProjectUI.*` so
Playwright can drive the pill-render path without a full agent run.
- `conftest.py`: `ENGINE_V2=true` added to the main `ironclaw_server`
fixture env. Without this the engine state stays uninitialized,
`/api/engine/projects` 500s with "engine not initialized", and every
coding-project scenario fails before the first assertion.
Quality gate
- `cargo fmt --all`
- `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` — clean
- `cargo test --lib tools::builtin::file` — 58 passing
- `pytest scenarios/test_coding_project_flow.py -k pills -v` — 2 passing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Paranoid Architect Review — Request Changes2 Critical, 5 High, 5 Medium findings. This is the highest-risk PR in the batch — 6K+ lines touching gateway, engine, TUI, and skills with two directly exploitable security issues. Critical
High
Medium
Blocking before merge
|
Paranoid Architect Review — Request Changes2 Critical, 5 High, 5 Medium findings. This is the highest-risk PR in the batch — 6K+ lines touching gateway, engine, TUI, and skills with two directly exploitable security issues. Critical
High
Medium
Blocking before merge
|
| form.className = 'project-modal-form'; | ||
| form.innerHTML = '' | ||
| + '<h4>' + escapeHtml(t('project.modal.editTitle', 'Edit project')) + '</h4>' | ||
| + '<label>Name<input type="text" id="pe-name" value="' + escapeHtml(nameValue) + '"' |
There was a problem hiding this comment.
Critical: XSS via HTML attribute injection. escapeHtml() uses the textContent/innerHTML DOM technique which escapes <, >, & but does NOT escape double quotes. This <input value="...escapeHtml(x)..."> construction is vulnerable to attribute breakout.
A project name like " onfocus="alert(document.cookie) injects arbitrary event handlers into 5 input fields in the edit form.
Fix: Either use a proper attribute escaper that also handles " and ', or build DOM elements programmatically with input.value = rawString instead of string interpolation.
| // Resolve the host path: explicit override or default ~/.ironclaw/... path. | ||
| let project_id = uuid::Uuid::parse_str(&info.id).ok()?; | ||
| let resolved_path = info | ||
| .workspace_path |
There was a problem hiding this comment.
High: Arbitrary directory traversal. workspace_path is accepted as any arbitrary PathBuf with zero validation — no check that it's under ~/.ironclaw/, no rejection of sensitive directories (/etc, /root). A user can create a project with workspace_path="/etc" and then run !cat shadow in shell mode.
The shell tool's blocklist catches cat /etc/shadow but NOT cat shadow when the workdir is already /etc.
Fix: Validate that workspace_path resolves to a path under the allowed workspace root (e.g., ~/.ironclaw/workspaces/), or at minimum reject paths outside $HOME.
| } | ||
| } | ||
| for (k, v) in env { | ||
| cmd.env(k, v); |
There was a problem hiding this comment.
Medium: Caller env vars override safe whitelist. This applies caller-supplied env vars AFTER setting safe vars (PATH, HOME, etc.), meaning a caller can inject PATH=/evil/bin or LD_PRELOAD=/evil.so.
Fix: Apply safe vars last (after caller env), or filter out security-critical keys from the caller's env map.
Two pre-existing staging-merge regressions in the coding-flow e2e suite: 1. Assistant thread now intentionally returns `project: None` (#2751), so after `project_set_active` the chrome still showed "No project". `refreshCurrentThread` now mirrors the backend `resolve_thread_project` precedence — per-thread override → active pointer → none — and fetches the active-project pointer when the thread has no override. Makes the home/assistant thread surface the user's currently-selected coding project instead of appearing disconnected. 2. `currentThreadId` was module-scoped and not reachable from Playwright. Expose a read-only `window.currentThreadId` getter so scenarios can observe thread binding without postMessage wiring. 3. `test_shell_mode_rejects_when_no_project` asserted a 409 contract that was deliberately retired in 0323ead (shell mode now falls back to the per-user `default` project). Renamed to `test_shell_mode_falls_back_to_default_project` and flipped the assertion to 202 so the new contract is pinned; the security invariant (shell runs against a project cwd, not the gateway's) is still covered by `test_shell_mode_roundtrip_persists_across_reload`. All 7 scenarios in `test_coding_project_flow.py` pass; `test_project_detail` still passes alongside. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 74 out of 74 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let out = tool | ||
| .execute(serde_json::Value::Object(params), &ctx) |
There was a problem hiding this comment.
The TUI shell-mode path executes the shell tool directly via Tool::execute, bypassing ToolDispatcher’s approval/audit pipeline. That means commands that are meant to be approval-gated (e.g. git reset --hard, git clean -f) can run without any approval checks. Route shell-mode through the dispatcher (if available here) or explicitly enforce requires_approval before executing.
| let out = tool | |
| .execute(serde_json::Value::Object(params), &ctx) | |
| let params = serde_json::Value::Object(params); | |
| if tool.requires_approval(¶ms) { | |
| return Err("shell command requires approval and must be executed through the approval pipeline".to_string()); | |
| } | |
| let out = tool | |
| .execute(params, &ctx) |
| fn parse_shell_output(value: &serde_json::Value) -> Option<ShellResult> { | ||
| // The shell tool returns a structured JSON object with stdout/stderr/ | ||
| // exit_code; older builds may have returned a string. Accept either | ||
| // shape defensively — the cache should not panic on a shape drift. | ||
| if let Some(obj) = value.as_object() { | ||
| let stdout = obj | ||
| .get("stdout") | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or("") | ||
| .to_string(); | ||
| let exit_code = obj.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0) as i32; | ||
| return Some(ShellResult { stdout, exit_code }); | ||
| } |
There was a problem hiding this comment.
parse_shell_output is looking for stdout, but the built-in shell tool returns { "output": ..., "exit_code": ... } (see src/tools/builtin/shell.rs). As a result, the project context cache will treat every command as having empty output and branch/dirty/PR probing won’t populate. Update this parser to read from output (and keep the string fallback if desired).
| fn spawn_stale_refreshes( | ||
| self: &Arc<Self>, | ||
| project_id: ProjectId, | ||
| workspace_path: &Path, | ||
| user_id: &str, | ||
| snapshot: &CachedContext, | ||
| ) { | ||
| let now = Instant::now(); | ||
| let workspace_path = workspace_path.to_path_buf(); | ||
|
|
||
| if is_stale(snapshot.branch_fetched_at, BRANCH_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path.clone(); | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_branch(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| if is_stale(snapshot.dirty_fetched_at, DIRTY_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path.clone(); | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_dirty(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| if is_stale(snapshot.pr_fetched_at, PR_TTL, now) { | ||
| let this = Arc::clone(self); | ||
| let user = user_id.to_string(); | ||
| let wp = workspace_path; | ||
| tokio::spawn(async move { | ||
| let _ = this.refresh_pr(project_id, &wp, &user).await; | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
get() can spawn duplicate refresh tasks for the same project/field when multiple requests arrive before the first refresh updates *_fetched_at (e.g. thread list + history in quick succession). This can lead to unbounded concurrent git/gh shell-outs and unnecessary load. Consider tracking per-project in-flight refreshes (or setting the fetched-at timestamp optimistically before spawning) so only one refresh per field runs at a time.
| let (stdout, stderr, exit_code, truncated, completed_at) = if let Some(next) = | ||
| iter.peek() | ||
| && next.role == "shell_output" | ||
| { | ||
| // safety: peeked `Some` two lines up; this next() cannot be None. | ||
| let Some(out) = iter.next() else { | ||
| continue; | ||
| }; | ||
| let parsed: serde_json::Value = | ||
| serde_json::from_str(&out.content).unwrap_or(serde_json::Value::Null); | ||
| ( | ||
| parsed | ||
| .get("stdout") | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or("") | ||
| .to_string(), | ||
| parsed | ||
| .get("stderr") | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or("") | ||
| .to_string(), | ||
| parsed | ||
| .get("exit_code") | ||
| .and_then(|v| v.as_i64()) | ||
| .unwrap_or(0) as i32, | ||
| parsed | ||
| .get("truncated") | ||
| .and_then(|v| v.as_bool()) | ||
| .unwrap_or(false), | ||
| Some(out.created_at.to_rfc3339()), | ||
| ) | ||
| } else { | ||
| (String::new(), String::new(), 0, false, None) | ||
| }; | ||
| turns.push(TurnInfo { | ||
| turn_number, | ||
| user_message_id: Some(msg.id), | ||
| user_input: msg.content.clone(), | ||
| response: None, | ||
| state: if exit_code == 0 { | ||
| "Completed".to_string() | ||
| } else { | ||
| "Failed".to_string() | ||
| }, |
There was a problem hiding this comment.
When a shell_command message has no following shell_output row, this builds a turn with exit_code = 0 and state Completed, which is misleading (it’s more likely “pending/unknown” or a failed/crashed dispatch). Consider using a sentinel exit code (e.g. -1) and/or a distinct state when the output row is missing or unparseable so the UI doesn’t show false success.
# Conflicts: # crates/ironclaw_engine/src/executor/orchestrator.rs # crates/ironclaw_gateway/static/js/core/history.js
…e-clone
Three live-test-driven improvements observed from the 2026-04-23 run against
nearai/ironclaw-e2e-test:
1. apply_patch description now leads with a concrete JSON example
({"path": "...", "old_string": "...", "new_string": "..."}) instead of
prose. LLMs follow shape-by-example better than imperatives.
2. fix-issue skill step 3 (worktree) is now mandatory and explicit:
numbered 7-step sequence, required `git worktree list` verification,
and an upfront "never cd outside /project/" guard. Step 2 gains a
mandatory thread_metadata_set call with its own JSON example — the
prior run made the http fetch but skipped the metadata update,
leaving the UI pills blank.
3. live-test harness pre-clones the fixture repo into the project
workspace tempdir. In production a dev project's workspace_path
already contains the checkout; the empty tempdir on first run made
the agent escape to /tmp instead of creating a worktree at the
project root.
Also: fix engine v2 `thread_metadata` field on three missed
`ThreadExecutionContext` construction sites in executor/context.rs tests
(carried over from the pre-merge branch into staging's expanded test
surface).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ue skill CodeAct (Tier 1) has a 30s Monty wall-clock cap that counts await time against it — so `await shell(command="cargo check")` inside a ```repl``` block times out well before the underlying shell tool's 120s budget. In the fix-issue live run against nearai/ironclaw-e2e-test, every cargo invocation inside CodeAct hit this. - `MONTY_CODEACT_TIMEOUT_SECS` env var now overrides the default, clamped to [1, 600]. Coding-agent workflows need minutes for `cargo check` / `cargo test`; safety-sensitive contexts can keep the 30s default. - The e2e_live_fix_issue_flow harness sets it to 300s at test startup. The fix-issue skill is rewritten to be CodeAct-first: - Every edit is `read_file` → `str.replace()` → `write_file` in Python instead of `apply_patch` (which LLMs consistently mis-serialize). - Long-running shell commands (`cargo test` etc.) are called as Tier-0 tool calls on their own turn — the skill explicitly documents which ops go in ```repl``` vs a plain tool call, and why. - The coding skill (which co-activates with fix-issue) was pushing "Prefer apply_patch" and had to be updated to match; it now recommends the CodeAct flow and bans `echo` narration. Across 5 live runs, this gets the agent past: skill activation → issue fetch → thread_metadata_set → git worktree → cargo clippy (100s+, via Tier 0) → write_file of new tool. Remaining gap: the agent still loses top-level bindings (`issue`, `title`, `worktree`) across CodeAct turns, so multi-turn workflows re-fetch the issue or re-derive the worktree path. That's LLM state consistency, not an engine bug — next lever is either a more capable model or a single-block skill template. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 77 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| use crate::tools::builtin::memory::WorkspaceResolver; | ||
|
|
There was a problem hiding this comment.
Unused import: WorkspaceResolver is imported but not referenced anywhere in this module. With -D warnings/clippy this will fail CI; please remove it (or use it if something is missing).
| let res = self | ||
| .run_shell( | ||
| user_id, | ||
| workspace_path, | ||
| "gh pr view --json number,title,url,state", | ||
| ) | ||
| .await?; | ||
|
|
||
| // Non-zero exit from `gh pr view` usually just means "no PR for the | ||
| // current branch" — treat as "no PR", not as an error. The chrome | ||
| // will simply omit the PR chip. | ||
| let pr = if res.exit_code == 0 { | ||
| parse_gh_pr(&res.stdout) | ||
| } else { | ||
| None | ||
| }; | ||
|
|
||
| let mut guard = self.entries.write().await; | ||
| let entry = guard.entry(project_id).or_default(); | ||
| entry.pr = pr; | ||
| entry.pr_fetched_at = Some(Instant::now()); | ||
| Some(()) | ||
| } | ||
|
|
||
| /// Clear a project's cache entry — used when `project_update` or | ||
| /// `project_delete` mutates a project so the next read does not serve | ||
| /// stale metadata for a moved `workspace_path`. | ||
| pub async fn invalidate(&self, project_id: ProjectId) { | ||
| self.entries.write().await.remove(&project_id); | ||
| } | ||
| } | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| struct ShellResult { | ||
| stdout: String, | ||
| exit_code: i32, | ||
| } | ||
|
|
||
| fn parse_shell_output(value: &serde_json::Value) -> Option<ShellResult> { | ||
| // The shell tool returns a structured JSON object with stdout/stderr/ | ||
| // exit_code; older builds may have returned a string. Accept either | ||
| // shape defensively — the cache should not panic on a shape drift. | ||
| if let Some(obj) = value.as_object() { | ||
| let stdout = obj | ||
| .get("stdout") | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or("") | ||
| .to_string(); | ||
| let exit_code = obj.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0) as i32; | ||
| return Some(ShellResult { stdout, exit_code }); | ||
| } | ||
| if let Some(s) = value.as_str() { | ||
| return Some(ShellResult { | ||
| stdout: s.to_string(), | ||
| exit_code: 0, | ||
| }); | ||
| } | ||
| None | ||
| } | ||
|
|
||
| fn parse_gh_pr(stdout: &str) -> Option<PrSummary> { | ||
| let raw = stdout.trim(); | ||
| if raw.is_empty() { | ||
| return None; | ||
| } | ||
| #[derive(Deserialize)] | ||
| struct GhPrRow { | ||
| number: u32, | ||
| title: String, | ||
| url: String, | ||
| state: String, | ||
| #[serde(default)] | ||
| is_draft: bool, | ||
| } |
There was a problem hiding this comment.
refresh_pr runs gh pr view --json number,title,url,state, but parse_gh_pr expects a draft flag (is_draft). GitHub CLI uses isDraft and it is only present if requested via --json isDraft. As written, draft PRs will be reported as open in the chrome. Consider adding isDraft to the command and deserializing with #[serde(rename = "isDraft")].
| /// Resolve which project a thread should show chrome for. | ||
| /// | ||
| /// Precedence: | ||
| /// 1. Per-thread override in `conversations.metadata.project_id`. | ||
| /// 2. User-level active pointer in `projects/_active.json`. | ||
| /// 3. `None` — the thread shows no project chrome. | ||
| /// |
There was a problem hiding this comment.
The doc comment for resolve_thread_project says precedence step 3 returns None (no project chrome), but the implementation always falls back to the user's default project. Please update the comment to match the actual behavior so callers and future maintainers don't rely on the wrong contract.
* docs(rules): add review-driven guidance for Claude Code Synthesizes recurring patterns from ~30 merged PRs, 147 bot review comments (Copilot/Gemini), human reviews, and ~50 issues filed in the past 2 weeks. Each rule cites the motivating PR/issue numbers. New files: - error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?, poisoned caches), persist-then-reload atomicity, channel-edge error mapping. (nearai#2526, nearai#2633, nearai#2653, nearai#2673, nearai#2546, nearai#2407, nearai#2408) - agent-evidence.md — side-effect claims must cite tool evidence, empty-fast outputs are errors, external-effect tools must read back, setup UI round-trip. (nearai#2544, nearai#2580, nearai#2582, nearai#2541, nearai#2545, nearai#2411, nearai#2543, nearai#2586) - lifecycle.md — discovery vs. activation, terminal auth rejection, list_installed vs. list_active, deactivation unwinds, snapshot rehydrate must re-validate. (nearai#2556, nearai#2557, nearai#2558, nearai#2564, nearai#2419, PR nearai#2617, PR nearai#2631) Extended: - types.md — from_trusted boundary rule, validated-newtype template with shared validate(&str), serde(try_from) required for validated types, wire-stable enums (no Debug; serde alias for migrations), canonical wire-contract field naming. (PR nearai#2685, nearai#2681, nearai#2687, nearai#2678, nearai#2669, nearai#2665, nearai#2683, nearai#2702) - safety-and-sandbox.md — every new ingress scans pre-transform/pre- injection, bounded resources (interners/streams/fan-out caps), cache keys must be complete. (nearai#2491, nearai#2676, nearai#2470, nearai#2633, nearai#2673, nearai#2710, PR nearai#2702) - review-discipline.md — PR scope discipline, guardrail scripts are code (regression tests, grouped-import parsing, CI has_code inclusion), absolute-path ban in committed docs, stale comments after refactors. (PR nearai#2668, nearai#2628, nearai#2680, nearai#2687, nearai#2647, nearai#2689, nearai#2701) All new files carry paths: frontmatter so they auto-load only on matching files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(rules): split agent-evidence into prompt + code rule agent-evidence.md mixed two concerns: runtime agent instruction (what the LLM should do when concluding a turn) and code-enforcement rules (what the dispatcher, engine, and tools must implement). Rules under .claude/rules/ only guide Claude Code when editing the repo — the runtime agent never reads them. Splits the two: - crates/ironclaw_engine/prompts/codeact_postamble.md — new section "Evidence before claiming side effects". Sits next to the existing "FINAL() answer quality" guidance; loaded via include_str! in executor/prompt.rs (no Rust change needed). - .claude/rules/tool-evidence.md — renamed from agent-evidence.md, keeps only the code invariants (engine v2 side-effect gate, empty-fast ToolError::EmptyResult, external-effect tools must read back, setup UI round-trip). Prompt tests pass unchanged; the postamble addition is pure text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * prompt: tighten evidence rule to FINAL() claims only, not tool use Live-test validation of the "Evidence before claiming side effects" section (added in the prior commit) showed it inhibited legitimate tool use. With the original wording, `zizmor_scan_v2` live-recording timed out at 302s with zero responses; reverting the postamble restored healthy behavior (88s run, 8 shell calls including `cargo install zizmor` and full workflow analysis). The original phrasing conflated two things: what the agent should claim and what tools it should call. The rule is only about the claim. Re-tunes the section to: - Open with an explicit "this does not restrict tool calls" scope. - Drop the "<1ms = failure" heuristic (too broad — normal tools like `tool_info(schema)` are legitimately fast). - Drop the full enumeration of forbidden side-effect verbs; keep the rule narrower and clearer. - Shorten the code example (remove redundant early-return). Re-tuned run: agent is active (shell calls, real reasoning), live recording completes in ~9s. The remaining test failure is a pre-existing assertion bug (exact `t == "shell"` match against tool strings that now carry arguments like `"shell(cmd)"`) — reproduces with the old postamble too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(live): fix tool-name assertions + re-record zizmor traces The two `zizmor_scan*` live tests had four broken tool-name assertions that silently failed to match: `tools.iter().any(|t| t == "shell")` against a tool list that now contains `"shell(cmd)"` strings (tool events carry args via `format_action_display_name` in `src/bridge/router.rs`). Two of the four were negative assertions checking for the absence of `tool_install` recovery loops — those silently passed even when a recovery loop actually ran. `sandbox_live_e2e.rs:203` already used the correct `t == "shell" || t.starts_with("shell(")` pattern; applied it consistently to all four sites. Verified live: - `IRONCLAW_LIVE_TEST=1 cargo test --test e2e_live -- zizmor_scan --ignored --test-threads=1` → 2 passed, 0 failed, 51.78s. Agent installs and runs zizmor end-to-end, producing real findings (exit code 14, dangerous triggers, excessive permissions, etc.). Traces re-recorded with the tuned postamble (commit 50d8517) and scrubbed: replaced `/home/illia/.cargo/bin/zizmor` with `/home/user/.cargo/bin/zizmor` per the developer-local-path ban in `.claude/rules/review-discipline.md`. No credentials, PII, or high-entropy secrets in either trace (only git SHAs from zizmor's workflow analysis output). Replay still passes: `cargo test --test e2e_live -- zizmor_scan --ignored` → 2/2 ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(replay): update zizmor_scan_v2 insta snapshot The engine v2 replay-snapshot gate (`engine_v2_tests::snapshot_zizmor_scan_v2`) failed against the re-recorded trace from 1691efe because the old snapshot encoded a broken run: - final_state: Failed - Missing Assistant message role - 3 issues: thread_failure (error), no_response (warning), llm_error (error) - 6 tool calls that never produced a final answer The new trace completes cleanly: - final_state: Done - System / User / Assistant roles present - 1 issue: mixed_mode (info) - 3 shell tool calls + successful `FINAL()` with real findings The snapshot was pinning a regression. Regenerated with `INSTA_UPDATE=always cargo test --test e2e_engine_v2 -- snapshot_zizmor_scan_v2`; passes on replay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(rules): address PR nearai#2714 review feedback - review-discipline: reword "Doc Absolute Paths" as a review convention (pre-commit only scans .rs; the rule misleadingly claimed enforcement). - safety-and-sandbox: broaden `paths:` frontmatter to include the actual ingress owners (`bridge`, `channels`, `workspace`, `agent`, engine crate) so the rule auto-loads where it applies. - tool-evidence: mark the side-effect gate, empty-fast rule, and `unverified` flag as target/aspirational invariants — neither `ToolError::EmptyResult`, an `unverified` field on `ToolOutput`, nor a byte-count field on `ActionRecord` exist today. Point at concrete interim conventions (`ToolError::ExecutionFailed`, `unverified: true` in the JSON result body). - types: scope "Validated newtypes must gate Deserialize" to *new* types, document the `CredentialName`/`ExtensionName` exception (they intentionally use `#[serde(transparent)]` + derived `Deserialize` under the `serde_does_not_revalidate` test). Clarify the `from_trusted` trust boundary (trusted = typed upstream, untrusted = raw JSON field even if the field *name* is "registry entry"). Switch `new` template to `impl Into<String>` to avoid an unnecessary clone when an owned `String` is passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(rules): simplify types.md + split doc-hygiene; address review round 2 - types: collapse two templates into one canonical validated-newtype shape. New types use `#[serde(try_from = "String")]` with a shared `validate(&str)` helper — no more dual "transparent for some / try_from for others" guidance. `CredentialName`/`ExtensionName` are documented as the sole legacy exception (locked in by the `serde_does_not_revalidate` test); new code must not copy their `transparent` + `from_trusted` pattern. Removes the long "Using `from_trusted` safely" section and the separate "Validated newtypes must gate Deserialize" subsection that contradicted the Don'ts list. - doc-hygiene: new tiny rule file scoped to `**/*.md`, `**/*.py`, `docs/**` that carries the "no developer-local absolute paths in committed docs" convention. Removed from review-discipline.md where its `src/**/*.rs` scope meant the rule never loaded on the files it governed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(live): match hyphenated tool-install in attempted_relevant_tool The engine records `action_name` as the raw string the LLM emitted (`crates/ironclaw_engine/src/executor/structured.rs:381`), and the registry's lookup canonicalization only affects dispatch — not the name that reaches `StatusUpdate::ToolStarted`. The two other predicates in this file (`bad_recovery` at :420, `phase_b_recovery` at :531) already defend against both forms; this one should too, for consistency. Addresses PR nearai#2714 review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Turns the web gateway into a coding-agent front-end:
Project(no new type) and extend it with aGitHubReponewtype + typedmetadataview (github_repo,default_branch) surfaced throughEngineProjectInfo. Conversations bind to a project viaconversations.metadata.project_id; the active-pointer lives as a workspace MemoryDoc atprojects/_active.json.#tab-chatshows project name · folder · branch (dirty dot) · PR chip. Populated by a TTL'dProjectContextCachethat shells out togit/ghthrough the dispatcher — the list endpoint never blocks on the network.!toggles a CSS badge and routes throughPOST /api/chat/send mode=shell. The backend resolves the thread's active project, dispatchesshellwithworkdir = project.workspace_path, and broadcastsshell_command/shell_outputSSE events. Commands + output persist asrole=shell_command/role=shell_outputmessages so history replay rebuilds the turn and the next LLM turn sees them in context.skills/coding-repo/SKILL.mdguides branch/test/PR discipline (PRs targetstaging, never--no-verify, never force-push tomain/staging). The skills parser gainsactivation.context.require_project_fieldso the skill only fires when the active project has a GitHub repo configured — non-gated callers are unaffected (default context admits every skill).All gateway write paths route through
ToolDispatcher::dispatch()(four new project-admin tools). Read paths are annotateddispatch-exempt: read aggregationper `.claude/rules/tools.md`.Test plan
cargo fmt --allcargo clippy --all --benches --tests --examples --all-features -- -D warnings— zero warningscargo test -p ironclaw_skills --lib— 171 passingcargo test -p ironclaw --lib— 5170+ passing (one pre-existing telegram test flakes on test-order; passes in isolation before and after these changes)cargo test --test project_admin_integration --features libsql— 5 passingIRONCLAW_LIVE_TESTS=1 cargo test --test live_coding_flow --features libsql -- --ignored— 4 passing against a realnearai/ironclawclonescripts/pre-commit-safety.sh— cleanscripts/check_gateway_boundaries.py— no new back-edgestest_coding_project_flow.py— 5 scenarios (chrome render, shell-badge toggle, shell turn round-trip + reload, no-project 409,GitHubRepoHTTP-boundary validation). Runs against mock LLM; needs e2e venv + chromium (seetests/e2e/CLAUDE.md)./data/illia/ironclaw5via the manage-projects modal, set active, confirm chrome populates; type!cargo check, confirm shell turn renders and persists across reload.Follow-ups
tests/live/README.md) — LLM-drivencoding-repoactivation that opens a real draft PR and tears down.crates/ironclaw_engine) so context gating applies to both v1 and v2 skill selection.🤖 Generated with Claude Code