19 Commits
Author SHA1 Message Date
ZacharyZhang-NY d6f216facd Fix Windows build: portable lock-contention check in graph_project
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
libc::EWOULDBLOCK is unix-only (kigi-shell links libc behind cfg(unix));
the v0.1.3 windows-msvc release build failed on it. fs2 exposes
lock_contended_error() precisely as the cross-platform classifier
(EWOULDBLOCK on unix, ERROR_LOCK_VIOLATION on Windows) — use it instead
of the raw errno. No behavior change on unix; graph_project lock tests
green.
2026-07-20 21:20:43 -04:00
ZacharyZhang-NY 4d2cf7ffd4 Bump version to 0.1.3 2026-07-20 20:46:35 -04:00
ZacharyZhang-NY 02cf5deebd Add /graph G6: plan-boundary topology optimizer
A restricted optimizer pass now reviews the graph at plan boundaries —
right after initial planning and piggybacked on each replan version
boundary, never mid-execution. An optimizer subagent may emit four ops
over Waiting/Ready nodes only: remove_dep (delete a false dependency,
restoring parallelism — the highest-value edit), reorder (pending
priority for the serial scheduler), merge (fold two tiny nodes; specs
concatenate, deps union, dependents re-point, absorbed self-deps drop),
and split (2-3 focused replacements inheriting the original's deps and
dependents). The optimizer changes graph DATA only; the executor stays
pure deterministic Rust. KIGI_GRAPH_OPTIMIZER=0 disables it entirely.

apply_optimization enforces the contract twice: per-op checks
(pending-only targets, known ids, terminal node untouchable, dead-node
deps rejected as DeadDep, merge/split targets with non-pending
dependents rejected with the true reason instead of tripping the
immutable invariant later), then FINAL invariants — every non-pending
node byte-identical in the result, the gn-final gate rebuilt over all
survivors, node cap, whole-graph acyclicity, and a BIDIRECTIONAL status
re-derivation for pending nodes (adversarial review caught the critical
hole: a merge grafting unsatisfied deps onto a Ready node would
otherwise dispatch it ahead of its new prerequisites, since
recompute_ready is promote-only). Applied passes bump plan_version,
freeze an immutable baseline, and consume a slot of the SHARED replan
cap; an explicit {"ops": []} is a respected free no-op; any failure
degrades to keeping the current plan. Plumbing reuses a new shared
artifact-pass runner (stale-artifact delete, size cap, missing-file
fail-closed) extracted from the replanner.

Tests: remove_dep parallelism restore + loud no-such-dep, immutable and
terminal-node rejections across all four ops, merge/split dependent
rewiring incl. final-gate rebuild and intra-split dep resolution,
result-cycle rejection, dead-dep splits, Ready-demote-on-merge, and
three e2e flows — false-dep removal proven ACTUALLY parallel by the
held-reply fan-out gate, OPTIMIZER=0 spawning zero passes, and the
shared-cap guard. kigi-shell 4959 lib tests green; clippy clean.
2026-07-20 20:21:49 -04:00
ZacharyZhang-NY db05ed0751 Add /graph G5: box-drawing DAG rendering for /graph show
/graph show now renders the dependency graph as box-drawing text via a
deterministic Sugiyama-lite pipeline in session/graph_render.rs:
longest-path layering, dagre-style dummy pass-throughs so every drawn
edge spans exactly one layer gap (long edges route as vertical lanes
through intermediate bands), one-pass barycenter ordering, and greedy
bus-lane allocation in the connector gutters with box-drawing-aware
glyph merging (a bus crossing a pass-through renders ┼). Node boxes
carry a status glyph (✓ ▶ ○ · ✗ ⊘) and a clamped title; a legend line
closes the view. Only Blocks edges are drawn — DiscoveredFrom is audit
metadata whose origin is always terminal, so drawing it would double
edges without scheduling meaning.

The output rides ordinary scrollback (the pager already scrolls it), so
no new wire variant or pager view was needed. Honest ceiling: a graph
wider than the 120-column budget — or an empty/pre-planning graph —
degrades to the indented status tree, because box art wrapped by the
terminal is worse than no art; a cycle reaching the renderer (upstream
validation bypassed) refuses to render rather than looping.

Tests: six-node structural snapshot (layering order, fan-in bands,
arrowheads, corners, legend, no trailing whitespace, width bound),
determinism across runs, too-wide fallback, dummy-lane routing through
intermediate bands, DiscoveredFrom exclusion, title clamping, empty
graph; plus a handle_prompt e2e driving /graph show end to end (no-graph
degrade + seeded chain rendering). kigi-shell 4949 lib tests green;
workspace clippy clean.
2026-07-20 19:53:41 -04:00
ZacharyZhang-NY 771de1822d Add /graph G4: project-level shared graph in .kigi/graph.jsonl
The graph now follows the REPOSITORY, not the session. Every checkpoint
projects the orchestration to .kigi/graph.jsonl at the git root in a
beads-style, line-mergeable shape: line 1 is the header (orchestration
minus nodes — omitted entirely, and a header carrying inline nodes is
rejected on load rather than silently duplicating the per-line entries),
then one content-hash-id node per line. The session tracker stays the
single source of truth; projection failures warn loudly but never block
progress. kigi only writes the file — committing it stays a user decision.

Single-writer discipline via an fs2 flock on a sidecar .lock: the session
that creates or resumes a graph owns the projection; other instances get a
read-only /graph status view rendered from the file and an explicit
refusal on resume. Cross-session revive: /graph resume in a fresh session
loads the file UNDER the lock (locking after reading raced the owner's
final checkpoint and could resurrect a just-cleared graph), sanitizes it
with the from_snapshot demotions, and re-dispatches.

Holding the lock proves nobody writes NOW — not that the file's content is
yours. Every lock-then-mutate site therefore identity-checks the projected
graph_id: /graph <objective> refuses to overwrite a foreign non-Complete
projection (revive-or-clear guidance, mirroring the session-level guard);
session-restored graphs claim writership on resume (and best-effort at
spawn re-emit, so the shared file learns the demoted truth immediately)
but refuse when the projection belongs to a different graph; /graph clear
skips projection teardown entirely when no session graph exists, leaves
foreign projections in place, and warns instead of swallowing lock errors.
Resume arms validate their flags before taking the lock.

Tests: projection round-trip pinning the one-line-per-node shape,
inline-nodes rejection, malformed-content loud errors, exclusive-lock
semantics across handles, and an e2e driving create → checkpoint
projection → second-instance read-only refusal → owner death → fresh-
session revive (demoted node relaunches) → clear removing the projection.
kigi-shell 4941 lib tests green; workspace clippy clean.
2026-07-20 19:41:54 -04:00
ZacharyZhang-NY bb5cbff62d Add /graph G3: dynamic replan from DISCOVERED work items
Workers, verifiers, and serial node goals can now surface out-of-scope work
as line-anchored 'DISCOVERED: <text>' markers (fence-stripped and
placeholder-filtered — the templates' own examples are fenced so verbatim
echoes never parse; worker summaries embedded in verifier prompts get the
marker neutralized alongside NODE_RESULT/NODE_VERDICT). Discoveries queue on
the orchestration as persisted state and fold into the graph at dispatch
boundaries: a replanner subagent produces a strictly APPEND-ONLY appendix,
validated against the live graph (existing-id deps allowed; edges onto
gn-final rejected — they would cycle the moment the final-gating extension
lands; Blocks deps on Failed/Blocked nodes rejected as DeadDep so the
attempt-2 feedback loop repairs the artifact). Installing an appendix bumps
plan_version, freezes an immutable graph.baseline.v{N}.json next to the
prior versions, extends gn-final's gate (demoting a Ready final back to
Waiting), and recomputes readiness.

DiscoveredFrom edges are audit metadata, never scheduling gates: an origin
is always terminal at replan time, so gating on it is either a no-op or a
permanent wedge — and a failed node's discoveries are still real work.
Replanning is bounded by KIGI_GRAPH_REPLAN_CAP (default 3; 0 disables it
quietly): past the cap, after the final node has achieved, or on replan
failure, discoveries drain to history only — a working graph is never
paused for a failed enhancement pass, and it always converges. The budget
gate now precedes the replan boundary (a budget-dead graph keeps its
discoveries queued for a later --budget top-up instead of spending two
replanner runs first), and both planner runners delete stale artifacts
before spawning so a child that responds without writing can never get a
previous pass's file validated as its own output.

Tests: validate_replan unit coverage (existing-id resolution,
DiscoveredFrom dedup, collisions, dead deps vs dead origins, terminal-node
edges, combined-graph cycles), tracker appendix/regate/audit-edge tests,
and two e2e flows — discovery → replan → appended node runs to Achieved
with both baselines frozen, and cap-0 draining to history while the graph
still converges. kigi-shell 4935 lib tests green; workspace clippy clean.
2026-07-20 19:17:24 -04:00
ZacharyZhang-NY 1579558b56 Add /graph G2: resumable budget, GraphUpdated status chip, PTY + turn-level coverage
BudgetLimited is now a resumable state: a budget trip demotes in-flight
nodes to Ready (a resource stop, not a verdict — no forever-Running node is
ever persisted) and '/graph resume --budget <tokens>' re-arms the graph with
fresh headroom (new budget = spent-so-far + extra). The tripped node's
partial burn is charged into tokens_spent_nodes at BOTH cascade sites before
the demotion clears current_node, so the top-up arithmetic never runs on an
under-counted ledger. Any input starting with 'resume' resolves to a resume
(case-insensitive; malformed top-ups surface the usage hint) and setup_graph
refuses to replace any non-Complete graph — a typo can no longer silently
destroy a resumable graph. An explicit --budget on a merely-paused graph is
rejected loudly instead of silently discarded; all trip-time messages now
advertise the top-up.

The pager gains a graph status chip: a new GraphUpdated wire variant
(extensions/notification.rs, old pagers degrade via #[serde(other)]) is
emitted from the single persist_graph_state chokepoint — every transition is
both a checkpoint and a badge tick — with a 'cleared' sentinel on /graph
clear and a one-shot re-emit after session restore (the replayed updates log
otherwise shows the pre-shutdown Active state that from_snapshot just
demoted in memory). TUI side: GraphDisplayState, session-notification arm,
and a goal-idiom chip with node progress, clamped current-node title, and
budget-aware spend. Pre-session command availability now advertises /graph
from the flags (it was fail-closed to the in-session path only, so the
welcome-screen slash menu never showed it).

Coverage: GraphUpdated wire round-trip + minimal-payload + unknown-tag
tests; PTY scenarios graph_slash_presession{,_disabled}.yaml (both run
green against the real pager binary); handle_prompt-level e2e for terminal
slash outcomes (/graph status|resume|pause, /goal refusals while the graph
owns the engine); budget top-up e2e driving a BudgetLimited diamond back to
Complete. Not shimmed: pre-G2 persisted snapshots with budget-Failed nodes
(the KIGI_GRAPH flag has never shipped enabled, so none exist).

kigi-shell 4927 and kigi-tui 6610 lib tests green; workspace clippy clean.
2026-07-20 16:35:02 -04:00
ZacharyZhang-NY 4d1e4fdc52 Add /graph G1: parallel fan-out with worktree isolation and merge-back
With KIGI_GRAPH_CONCURRENCY > 1 (default 3, clamp [1,8]) and >=2 Ready nodes,
drive_graph — the single dispatch loop shared by setup/advance/resume — runs
parallel batches: each node executes as a bounded worker<->verifier subagent
loop (KIGI_GRAPH_NODE_ROUNDS, default 3; general-purpose children with the
full implementer toolset; worktree isolation on round 1, resume keeps context
and worktree on later rounds; NODE_RESULT/NODE_VERDICT terminal contracts
parsed fail-closed with fence-stripping and line anchoring). Achieved nodes
merge back SEQUENTIALLY via kigi_workspace apply_worktree in Merge mode; a
conflict fails the node, block_dependents fires, and surviving chains keep
going. gn-final always runs serially on the full goal engine. Concurrency=1
is byte-identical to the serial G0 path; non-git projects degrade to serial.

Merge primitive hardened for real use (kigi-workspace): the 3-way apply is
now byte-safe (binary files no longer read as UTF-8 and silently deleted)
and gains the identical-content rule (ours==theirs => already present, not a
conflict) — without it, any dirty file inherited via PreserveWorkingTree
false-conflicted every node merge and multi-wave graphs self-poisoned.

Adversarial review pass (16 confirmed findings, all fixed): in-session
resume demotes orphaned Running nodes instead of wedge-pausing forever after
a mid-batch Esc; cancel re-sweeps subagents AFTER the turn abort so workers
spawned in the cancel window die too; empty child ids are never adopted as
resume targets (no unisolated escape to the shared tree); a successful
isolated round returning no worktree fails the node (soft-fallback can no
longer put N writers in one tree); main-HEAD movement during a batch aborts
merges instead of reverse-applying external commits; failed nodes still
charge the token budget; budget trips terminally fail the in-flight node;
runaway (>600s) rounds are cancelled by spawn id and retried via resume;
worker summaries are marker-sanitized before verifier embedding. Merged
worktrees are removed immediately (storage discipline); failed nodes keep
theirs for postmortem.

Tests: 4922 kigi-shell lib tests green (58 graph-specific), including
fan-out proven by a held-reply gate, real-git batch merge with cleanup
assertions, budget charging across verdicts, cap trimming, resume-after-
cancelled-batch, and backgrounded-round cancel semantics.
2026-07-20 15:32:35 -04:00
ZacharyZhang-NY 4361b03259 Add /graph G0: serial graph engineering mode over the goal engine
A deterministic DAG scheduler layered on the existing goal engine: /graph
<objective> decomposes the objective via a graph-planner subagent, gates the
result through Agentproof-style static validation (cycles, unknown deps,
duplicate slugs, caps), appends a structural final-verification node, then
executes each node as one ordinary goal — planner, worker loop, adversarial
verifier, budget and pause machinery all reused verbatim. The in-turn loop
advances nodes within the same turn (multi-loop closed loop); goal-side
auto-pauses cascade to the graph at a single chokepoint; node goals are armed
with the remaining graph budget so mid-node overruns trip graph-wide.

Gated by KIGI_GRAPH=1 (default off) + the goal harness. State persists to
<session_dir>/graph/state.json with a clear-tombstone; per-version immutable
baselines and per-node artifact archives live under graph/<graph_id>/.
Restore demotes Active->UserPaused and Running->Ready (verifier-gated re-run).

Review hardening (adversarial multi-agent pass, 24 confirmed findings fixed):
the goal-inactive loop break now consults the graph seam (mid-turn
classifier-disabled completions can no longer strand an Active graph), the
pause cascade fires even when no node goal is in flight (cancel during
planning), node bookkeeping precedes the long setup await, /graph clear only
resets the engine it owns, budget trips terminally fail the in-flight node,
and the pause transitions in /goal pause + /graph pause no longer hide inside
debug_assert! (a release-build no-op inherited from upstream).

Tests: 4908 kigi-shell lib tests green, including a serial 4-node closed-loop
e2e, restore/resume, cascade, mutual-exclusion, planning-retry, persistence
round-trip + tombstone, and status rendering.
2026-07-20 14:07:45 -04:00
ZacharyZhang-NY 2c29d3ecd5 Bump version to 0.1.2
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
2026-07-18 20:25:51 -04:00
ZacharyZhang-NY de534582c1 Drop the CI workflow; gates run locally, builds only on release tags 2026-07-18 20:25:51 -04:00
ZacharyZhang-NY 54c9177ce2 Texture the welcome moon with lunar maria
Small scattered mare spots: dark holes on the sunlit disc, faint gray
patches on the dark limb.
2026-07-18 20:25:51 -04:00
ZacharyZhang-NY 7bdf50a716 Bump version to 0.1.1
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
Fix round on top of v0.1.0: installers persist PATH themselves,
install.ps1 strict-mode crash fixed, /feedback opens GitHub issues,
moon-phase waiting spinner, Changelog feature excised.
2026-07-18 12:33:48 -04:00
ZacharyZhang-NY 10d0c0816f Excise the Changelog feature
Kigi never publishes CDN changelogs, so the entire inherited feature
was dead weight: the welcome-menu Changelog row, the hero-box info
slot (bullets + clickable CTA), /release-notes with its /changelog
alias, and the ChangelogManager CDN-fetch/disk-cache pipeline
(Effect::FetchChangelog, TaskResult::ChangelogFetched, startup and
post-login fetch kickoffs, AppView cache fields, mouse hover/click
handling). Welcome menu is now [Import] / New worktree / Resume
session / Quit; the hero box keeps title + version + subtitle and its
layout math simplifies to 3 + menu rows (verified equivalent by the
surviving boundary tests). Action::ShowReleaseNotes and the DocViewer
modal stay — /docs uses them. builtin.rs keeps deleting stale
CHANGELOG.{json,md} caches written by kigi ≤ 0.1.0.

kigi-shell-base drops its reqwest 'blocking' feature (only the deleted
module used it). -1206 lines net.

Gates: fmt clean, workspace check/clippy --all-targets 0/0, kigi-tui
lib 6609 / shell-base 56 / shell util:: 258 all passing, welcome pty
e2e (3 tests incl. braille logo) passing.
2026-07-18 12:31:52 -04:00
ZacharyZhang-NY 6e26d15428 Installers persist PATH themselves instead of printing instructions
install.sh: append the export line to the login shell's rc file
(zsh: $ZDOTDIR/.zshrc; bash: ~/.bash_profile on macOS, ~/.bashrc on
Linux; fish: fish_add_path in config.fish; otherwise ~/.profile).
Idempotent — a second run detects the existing entry and skips. On an
unwritable rc it fails loudly with the manual command.

install.ps1: prepend the bin dir to the per-user PATH via
[Environment]::SetEnvironmentVariable(..., 'User') — registry-backed,
picked up by every new terminal. Strict-mode-safe for a null user Path.

Verified end-to-end on macOS with a fake HOME: install → rc appended →
fresh zsh sources it → kigi resolves and runs. All shell branches,
ZDOTDIR/XDG overrides, idempotency, and the write-failure path covered
by a harness driving the real script tail; install.ps1 parse-checked
and its path-construction logic exercised under pwsh strict mode.
2026-07-18 12:07:57 -04:00
ZacharyZhang-NY 82ebcc38b7 Turn status: moon-phase spinner for Waiting for response
The active-turn spinner next to "Waiting for response…" now cycles
the moon phases 🌑🌒🌓🌔🌕🌖🌗🌘 — the official kimi-cli's lunation
animation (rich's `moon` spinner) — matching the welcome-screen moon
logo. New glyphs::moon_spinner_frames() with the same legacy-ConHost
ASCII fallback as every other spinner set; emoji frames are 2 columns
and the status row already measures the rendered frame, so layout
adapts. The ambient "Starting session…" row and other spinners stay
braille.

Verified: kigi-tui lib 6621 + pager-render 963 tests green; real-binary
PTY e2e waiting_for_model_label_shows_before_first_token passes with
the new spinner rendering in a live vt.
2026-07-18 12:00:52 -04:00
ZacharyZhang-NY 0dc98a34a6 /feedback opens the Kigi GitHub issues page
Feedback about an unofficial community build belongs on its own issue
tracker, not Moonshot's feedback endpoint — and this mirrors the
official kimi-cli, whose /feedback opens its repo's issues page
(ISSUE_URL in ui/shell/slash.py). /feedback now returns
Action::OpenUrl(https://github.com/ZacharyZhang-NY/Kigi-CLI/issues),
the same battle-tested browser path /docs web uses.

The now-dead TUI text-feedback pipeline is excised: PromptInputMode::
Feedback (~ prefix composer mode), Action::{EnterFeedbackMode,
SendFeedback}, Effect::SendFeedback (the kigi/feedback ACP POST),
TaskResult::{FeedbackComplete,FeedbackFailed}, and their dispatchers.
The shell-side kigi/feedback ACP extension stays: it is protocol
surface for editor embeddings, OAuth-gated, and shared with kigi/btw.

Gates: workspace check/clippy --all-targets 0/0, fmt, kigi-tui lib
6621 passed / 0 failed.
2026-07-18 11:51:13 -04:00
ZacharyZhang-NY 0692198719 install.ps1: fix PropertyNotFoundStrict crash in the PATH check
Under the script's own Set-StrictMode -Version Latest, .Count on the
result of Where-Object throws when the filter matches nothing — which
is precisely the fresh-install case (bin dir not on PATH yet), so every
first-time Windows install ended with an error after an otherwise
successful install. Use -contains on the split arrays instead; no
member access on a possibly-null pipeline result.

Repro + fix verified under pwsh 7.5.2 with StrictMode Latest: old
expression reproduces the user's exact error, new one returns
False/True correctly for missing/present PATH entries.
2026-07-18 11:37:08 -04:00
ZacharyZhang-NY 77fd457627 install.sh: print the permanent PATH command for the user's shell
The post-install guidance previously showed a one-time `export PATH=…`
that dies with the terminal. Detect $SHELL and print the persistent
command instead: append to ~/.zshrc / ~/.bash_profile (macOS) /
~/.bashrc (Linux), fish_add_path for fish (already persistent via
universal variables), ~/.profile as the POSIX fallback.
2026-07-18 11:35:39 -04:00
86 changed files with 10590 additions and 1664 deletions
-79
View File
@@ -1,79 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
gates:
name: check / clippy / fmt / deny (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-14, ubuntu-24.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- uses: Swatinem/rust-cache@v2
- name: cargo fmt
run: cargo fmt --all --check
- name: cargo check
run: cargo check --workspace --all-targets --locked
- name: cargo clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Install cargo-deny
run: cargo install cargo-deny --locked
- name: cargo deny advisories
run: cargo deny check advisories
test:
name: test (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-14, ubuntu-24.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- uses: Swatinem/rust-cache@v2
- name: cargo test
run: cargo test --workspace --locked
perf:
name: performance budgets (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-14, ubuntu-24.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- uses: Swatinem/rust-cache@v2
- name: Install hyperfine (macOS)
if: runner.os == 'macOS'
run: brew install hyperfine
- name: Install hyperfine (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y hyperfine
- name: Build release binaries
run: |
cargo build --release -p kigi-bin --locked
cargo build --release -p kigi-pager-pty-harness --bin pty-scenario --locked
- name: Enforce performance budgets
run: scripts/bench.sh target/release/kigi
+94
View File
@@ -49,6 +49,17 @@ import) or any `KIMI_*` env var.
- `third_party/` — vendored Mermaid rendering stack (untouched policy). - `third_party/` — vendored Mermaid rendering stack (untouched policy).
- `bin/protoc` — dotslash launcher used by proto codegen. - `bin/protoc` — dotslash launcher used by proto codegen.
## Storage discipline
- Tests that touch the filesystem MUST use `tempfile::TempDir` (drop
cleans up) — never bare `std::env::temp_dir()` + `create_dir_all`,
which leaks directories into the OS temp root forever.
- `target/` grows past 150GB across repeated full-workspace builds
(incremental is already off); run `cargo clean` when it exceeds
~50GB and at milestone boundaries.
- Graph node worktrees are removed right after a successful merge-back;
only FAILED nodes keep theirs for postmortem.
## Test seams ## Test seams
Cross-crate test hooks are behind the `test-support` cargo feature Cross-crate test hooks are behind the `test-support` cargo feature
@@ -56,6 +67,89 @@ Cross-crate test hooks are behind the `test-support` cargo feature
dependents' `[dev-dependencies]`. Don't expose new test seams as plain dependents' `[dev-dependencies]`. Don't expose new test seams as plain
`#[cfg(test)]` items across crate boundaries. `#[cfg(test)]` items across crate boundaries.
## Graph mode (`/graph`, post-0.1.x — plan.md in the parent dir)
A deterministic DAG scheduler layered over the goal engine: `/graph
<objective>` decomposes the objective into nodes (graph planner subagent
→ Agentproof-style static gate in `graph_plan.rs`), then executes each
node as one ordinary goal — the agentic loop lives INSIDE the node; the
edges stay deterministic Rust. The harness appends a terminal
`gn-final` verification node depending on every planner node.
- Feature flag `KIGI_GRAPH=1` (default off); availability additionally
requires the goal harness (`BuiltinGate::Graph`).
- Key modules (kigi-shell): `session/graph_tracker.rs` (pure state
machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`),
`session/graph_plan.rs` (planner-JSON contract + validation + fnv id
canonicalization), `session/graph_planner.rs` (planner runner, reuses
the goal planner spawn plumbing),
`session/acp_session_impl/graph.rs` (orchestration seam).
- Seam points: `handle_prompt` intercepts GraphSet/GraphResume; the
in-turn loop's `EndTurn` arm calls `run_graph_round_end()` to advance
nodes within the same turn; goal auto-pauses cascade to the graph in
`auto_pause_goal_if_active_inner`; node goals are armed with the
REMAINING graph budget so `enforce_goal_token_budget` cascades trips.
- Persistence: `PersistenceMsg::GraphModeState(Option<..>)`
`<session_dir>/graph/state.json` (`None` tombstones after clear);
immutable per-version baselines `graph/graph.baseline.v{N}.json`;
per-node goal artifacts archived to `graph/<node_id>/`. Restore
demotes `Active``UserPaused` and `Running``Ready` (re-run is safe:
the verifier gates completion).
- `/goal` and `/graph` are mutually exclusive while the graph owns the
engine; e2e suite: `acp_session_tests/graph/graph_e2e_tests.rs`.
- Parallel fan-out (G1): with `KIGI_GRAPH_CONCURRENCY > 1` (default 3,
clamp [1,8]) and ≥2 `Ready` nodes, `drive_graph` runs batches via
`acp_session_impl/graph_workers.rs` — per node a bounded
worker↔verifier subagent loop (`KIGI_GRAPH_NODE_ROUNDS`, default 3;
`general-purpose` children; worktree isolation on round 1, resume
keeps context+worktree on later rounds; `NODE_RESULT:` /
`NODE_VERDICT:` terminal contracts parsed fail-closed), then
SEQUENTIAL merge-back via `kigi_workspace::worktree::apply_worktree`
(`ApplyMode::Merge`); a conflict fails the node and blocks its
dependents while other chains continue. `gn-final` always runs
serially on the full goal engine. Concurrency=1 is byte-identical to
the serial G0 path. Ceiling: a worker round exceeding the foreground
subagent await budget (600s) is cancelled and retried via resume.
- G2: `BudgetLimited` is resumable — a budget trip demotes in-flight
nodes to `Ready` (resource stop, not a verdict) and
`/graph resume --budget <tokens>` re-arms with fresh headroom. The
pager shows a graph status chip driven by the `GraphUpdated` wire
variant (`extensions/notification.rs`), emitted from the single
`persist_graph_state` chokepoint (checkpoint ⇔ badge tick); old
pagers degrade via `#[serde(other)] Unknown`. PTY scenarios:
`graph_slash_presession{,_disabled}.yaml`.
- G3: dynamic replan. `DISCOVERED: <text>` line markers (fence-stripped,
placeholder-filtered) from workers/verifiers/the serial node's final
text queue as `pending_discoveries`; at each dispatch boundary
`maybe_replan_graph` (`acp_session_impl/graph_replan.rs`) runs a
replanner subagent producing an APPEND-ONLY appendix
(`validate_replan`: existing-id deps allowed, edges onto `gn-final`
rejected — they would cycle after the final-gating extension),
bumps `plan_version`, freezes `graph.baseline.v{N}.json`, and regates
`gn-final` (Ready→Waiting). Bounded by `KIGI_GRAPH_REPLAN_CAP`
(default 3, 0 = off); past the cap — and after the final node has
achieved — discoveries drain to history only. Replan failure degrades
(history + notice); it never pauses a working graph.
- G4: the graph follows the repo. Every checkpoint projects to
`.kigi/graph.jsonl` at the git root (`session/graph_project.rs`,
header line + one node per line, atomic write); single writer via an
fs2 flock sidecar; other instances get read-only `/graph status`.
Fresh sessions revive via `/graph resume` (load UNDER the lock,
from_snapshot demotions apply). All lock-then-mutate sites
identity-check the projected `graph_id`; kigi never commits the file.
- G5: `/graph show` renders box-drawing DAG art
(`session/graph_render.rs`, Sugiyama-lite: longest-path layers, dummy
pass-throughs, barycenter ordering, bus lanes). Wider than 120 cols
degrades to the status tree.
- G6: plan-boundary topology optimizer
(`acp_session_impl/graph_optimize.rs`; `KIGI_GRAPH_OPTIMIZER=0`
disables). Restricted ops (`remove_dep`/`reorder`/`merge`/`split`)
validated by `graph_plan::apply_optimization`: pending-only targets,
immutable nodes byte-identical in the result, terminal gate rebuilt,
whole-graph acyclicity. Applied passes bump `plan_version` and share
the replan cap; `{"ops": []}` is a respected free no-op; failures
degrade.
## Milestones (PRD §8.3) ## Milestones (PRD §8.3)
- M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/ - M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-acp-lib" name = "kigi-acp-lib"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"async-trait", "async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-agent" name = "kigi-agent"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 6.0.0", "dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-agent-lifecycle" name = "kigi-agent-lifecycle"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"tokio", "tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-auth" name = "kigi-auth"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"http 1.4.2", "http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-bin" name = "kigi-bin"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-chat-state" name = "kigi-chat-state"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"kigi-compaction", "kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-codebase-graph" name = "kigi-codebase-graph"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"ahash", "ahash",
"clap", "clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-compaction" name = "kigi-compaction"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-config" name = "kigi-config"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"base64", "base64",
"blake3", "blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-config-types" name = "kigi-config-types"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"indexmap", "indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-crash-handler" name = "kigi-crash-handler"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"libc", "libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-env" name = "kigi-env"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"tracing", "tracing",
"url", "url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-fast-worktree" name = "kigi-fast-worktree"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-file-utils" name = "kigi-file-utils"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"aws-config", "aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-fsnotify" name = "kigi-fsnotify"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"criterion", "criterion",
"dunce", "dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-gix-status" name = "kigi-gix-status"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"gix", "gix",
"kigi-test-utils", "kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hooks" name = "kigi-hooks"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"kigi-config", "kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hooks-plugins-types" name = "kigi-hooks-plugins-types"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-http" name = "kigi-http"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"kigi-auth", "kigi-auth",
"kigi-log", "kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hunk-tracker" name = "kigi-hunk-tracker"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"chrono", "chrono",
"dunce", "dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-interjection-core" name = "kigi-interjection-core"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "kigi-log" name = "kigi-log"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-markdown" name = "kigi-markdown"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"anstyle-lossy", "anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-markdown-core" name = "kigi-markdown-core"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"pulldown-cmark", "pulldown-cmark",
] ]
[[package]] [[package]]
name = "kigi-mcp" name = "kigi-mcp"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"async-trait", "async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-memory" name = "kigi-memory"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-mermaid" name = "kigi-mermaid"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"fontdb", "fontdb",
"image", "image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-models" name = "kigi-models"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"kigi-env", "kigi-env",
"serde", "serde",
@@ -5969,7 +5969,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-minimal" name = "kigi-pager-minimal"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"chrono", "chrono",
"crossterm", "crossterm",
@@ -5986,7 +5986,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-pty-harness" name = "kigi-pager-pty-harness"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
@@ -6011,7 +6011,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-render" name = "kigi-pager-render"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anstyle", "anstyle",
@@ -6063,7 +6063,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-paths" name = "kigi-paths"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"camino", "camino",
"serde", "serde",
@@ -6073,7 +6073,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-prompt-queue" name = "kigi-prompt-queue"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -6081,7 +6081,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-proto-build" name = "kigi-proto-build"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pbjson-build", "pbjson-build",
@@ -6092,7 +6092,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-ratatui-inline" name = "kigi-ratatui-inline"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"ansi-width", "ansi-width",
"anstyle-parse 0.2.7", "anstyle-parse 0.2.7",
@@ -6109,7 +6109,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-ratatui-textarea" name = "kigi-ratatui-textarea"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -6130,7 +6130,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sampler" name = "kigi-sampler"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"async-openai", "async-openai",
"async-stream", "async-stream",
@@ -6153,7 +6153,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sampling-types" name = "kigi-sampling-types"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"assert_matches", "assert_matches",
"async-openai", "async-openai",
@@ -6169,7 +6169,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sandbox" name = "kigi-sandbox"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -6190,7 +6190,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-secrets" name = "kigi-secrets"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"regex", "regex",
"serde_json", "serde_json",
@@ -6228,7 +6228,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-shell" name = "kigi-shell"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6365,7 +6365,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-shell-base" name = "kigi-shell-base"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -6390,7 +6390,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sqlite-journal" name = "kigi-sqlite-journal"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"libc", "libc",
"rusqlite", "rusqlite",
@@ -6401,7 +6401,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-subagent-resolution" name = "kigi-subagent-resolution"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"kigi-sampling-types", "kigi-sampling-types",
"kigi-tool-types", "kigi-tool-types",
@@ -6416,7 +6416,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-system-power" name = "kigi-system-power"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
"zbus", "zbus",
@@ -6424,7 +6424,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-test-support" name = "kigi-test-support"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-test-utils" name = "kigi-test-utils"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"runfiles", "runfiles",
"tracing", "tracing",
@@ -6455,11 +6455,11 @@ dependencies = [
[[package]] [[package]]
name = "kigi-token-estimation" name = "kigi-token-estimation"
version = "0.1.0" version = "0.1.3"
[[package]] [[package]]
name = "kigi-tool-protocol" name = "kigi-tool-protocol"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"kigi-tool-types", "kigi-tool-types",
"serde", "serde",
@@ -6470,7 +6470,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tool-runtime" name = "kigi-tool-runtime"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tool-types" name = "kigi-tool-types"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"minijinja", "minijinja",
"schemars 1.2.1", "schemars 1.2.1",
@@ -6498,7 +6498,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tools" name = "kigi-tools"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -6575,7 +6575,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tools-api" name = "kigi-tools-api"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"kigi-proto-build", "kigi-proto-build",
"kigi-tool-protocol", "kigi-tool-protocol",
@@ -6588,11 +6588,11 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tracing-macros" name = "kigi-tracing-macros"
version = "0.1.0" version = "0.1.3"
[[package]] [[package]]
name = "kigi-tty-utils" name = "kigi-tty-utils"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"libc", "libc",
"nix 0.30.1", "nix 0.30.1",
@@ -6602,7 +6602,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tui" name = "kigi-tui"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"ansi-to-tui", "ansi-to-tui",
@@ -6689,7 +6689,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-update" name = "kigi-update"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
@@ -6718,14 +6718,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-version" name = "kigi-version"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"semver", "semver",
] ]
[[package]] [[package]]
name = "kigi-workspace" name = "kigi-workspace"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6804,7 +6804,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-workspace-types" name = "kigi-workspace-types"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"base64", "base64",
"chrono", "chrono",
@@ -8838,7 +8838,7 @@ dependencies = [
[[package]] [[package]]
name = "ptyctl" name = "ptyctl"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
@@ -8856,7 +8856,7 @@ dependencies = [
[[package]] [[package]]
name = "ptyctl-cli" name = "ptyctl-cli"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.3"
edition = "2024" edition = "2024"
license = "Apache-2.0" license = "Apache-2.0"
+1 -1
View File
@@ -43,7 +43,7 @@ irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1
``` ```
```sh ```sh
kigi --version # kigi 0.1.0 … unofficial Kimi Code CLI community build kigi --version # kigi 0.1.1 … unofficial Kimi Code CLI community build
kigi login # sign in with your Kimi Code subscription (device-code flow) kigi login # sign in with your Kimi Code subscription (device-code flow)
kigi # start the TUI kigi # start the TUI
``` ```
+34 -4
View File
@@ -218,10 +218,12 @@ pub fn diamond_hollow_char() -> char {
/// 1-column ASCII spinner (`|`, `/`, `-`, `\`) on legacy ConHost. /// 1-column ASCII spinner (`|`, `/`, `-`, `\`) on legacy ConHost.
/// ///
/// The U+2800 Braille Patterns block is not part of CP437 and renders as /// The U+2800 Braille Patterns block is not part of CP437 and renders as
/// tofu on the legacy console raster font, so the turn-status line, the /// tofu on the legacy console raster font, so the starting-session row,
/// MCP-connecting chip, the image-viewer loader, and the `/btw` overlay /// the MCP-connecting chip, the image-viewer loader, and the `/btw`
/// all fall back to the classic ASCII spinner there. Every frame in both /// overlay all fall back to the classic ASCII spinner there. Every frame
/// sets is exactly 1 column so the surrounding layout never shifts. /// in both sets is exactly 1 column so the surrounding layout never
/// shifts. (The turn-status "Waiting for response…" line uses
/// [`moon_spinner_frames`] instead.)
pub fn braille_spinner_frames() -> &'static [&'static str] { pub fn braille_spinner_frames() -> &'static [&'static str] {
const FANCY: &[&str] = &[ const FANCY: &[&str] = &[
"\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}",
@@ -235,6 +237,34 @@ pub fn braille_spinner_frames() -> &'static [&'static str] {
} }
} }
/// Moon-phase spinner frames (`🌑🌒🌓🌔🌕🌖🌗🌘`) normally; the ASCII
/// spinner on legacy ConHost.
///
/// The turn-status "Waiting for response…" line reuses the official
/// kimi-cli's lunation animation (rich's `moon` spinner) — one full
/// lunation per cycle, matching the welcome-screen moon logo. Emoji
/// frames are 2 columns wide; the caller measures the rendered frame
/// (`spinner_str.width()`) so the layout adapts. Legacy ConHost's raster
/// font has no emoji, so it falls back to the 1-column ASCII spinner.
pub fn moon_spinner_frames() -> &'static [&'static str] {
const FANCY: &[&str] = &[
"\u{1f311}",
"\u{1f312}",
"\u{1f313}",
"\u{1f314}",
"\u{1f315}",
"\u{1f316}",
"\u{1f317}",
"\u{1f318}",
];
const FALLBACK: &[&str] = &["|", "/", "-", "\\"];
if is_legacy_windows_console() {
FALLBACK
} else {
FANCY
}
}
/// Pulsing dot progress-spinner frames (`⋅ : ⸬ ⁙`) normally; a quiet /// Pulsing dot progress-spinner frames (`⋅ : ⸬ ⁙`) normally; a quiet
/// 1-column dot cycle (`.`, `:`, `·`) on legacy ConHost. /// 1-column dot cycle (`.`, `:`, `·`) on legacy ConHost.
/// ///
+1 -1
View File
@@ -13,7 +13,7 @@ default-bazel = []
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] } reqwest = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@@ -1,362 +0,0 @@
//! Changelog fetching from CDN with local disk cache.
//!
//! Both markdown (`*.external.md`) and JSON (`*.external.json`) changelogs
//! are published per-version alongside the Kigi GitHub distribution.
//!
//! `ChangelogManager::fetch()` retrieves both formats in parallel and
//! returns a `Changelog` with optional markdown + structured entries.
//! Consumers pick the format they need:
//! - `/release-notes` uses `changelog.markdown` for rich scrollback display
//! - Welcome screen uses `changelog.entries` for bullet rendering
use std::path::PathBuf;
/// Base URL for published changelogs. Kigi distributes via GitHub, so
/// per-version changelogs live in the release repository. Unreachable or
/// missing files degrade gracefully to the on-disk cache (see `fetch_with`).
const CHANGELOG_BASE: &str =
"https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/changelogs";
const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// A single structured changelog entry from the published JSON changelog.
///
/// Shape must match the output of `render_external_json` in `changelog.sh`:
/// `{category, description, breaking_change}`
/// If you change fields here, update `changelog.sh:render_external_json` too.
///
/// All fields use `#[serde(default)]` so a single malformed entry doesn't
/// kill the entire array parse. Entries with an empty description are
/// filtered out by `bullets_from_entries`.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ChangelogEntry {
/// Category label (e.g. "features", "fixes", "breaking", "performance").
#[serde(default)]
pub category: String,
/// Human-readable description (may contain `**bold**` or backticks).
#[serde(default)]
pub description: String,
/// Whether this entry represents a breaking change.
#[serde(default)]
pub breaking_change: bool,
}
/// Both formats of a version's changelog, fetched together.
pub struct Changelog {
/// Rendered markdown (for `/release-notes` display).
pub markdown: Option<String>,
/// Structured entries (for welcome screen bullets).
pub entries: Option<Vec<ChangelogEntry>>,
}
/// Manages changelog retrieval from CDN with local disk caching.
///
/// Single entry point: `fetch()` returns both markdown and JSON in one
/// `Changelog` struct. Each format is fetched independently with its own
/// cache file, so a failure in one doesn't block the other.
pub struct ChangelogManager {
md_cache: PathBuf,
json_cache: PathBuf,
}
impl Default for ChangelogManager {
fn default() -> Self {
Self::new()
}
}
impl ChangelogManager {
pub fn new() -> Self {
// Prefer live `$KIGI_SHARE_DIR` so harness-injected homes (PTY e2e) always
// win over a OnceLock that may have been initialised earlier with a
// different path in the same process graph.
Self::from_env_home()
}
/// Resolve cache paths from the live process environment (not the
/// `kigi_home()` OnceLock). A seeded `$KIGI_SHARE_DIR` set on the pager
/// process is always honoured even if some earlier init path cached a
/// different home.
fn from_env_home() -> Self {
let home = std::env::var_os("KIGI_SHARE_DIR")
.map(std::path::PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(crate::util::kigi_home::kigi_home);
Self {
md_cache: home.join("CHANGELOG.md"),
json_cache: home.join("CHANGELOG.json"),
}
}
/// Fetch both markdown and JSON changelogs for the current version.
///
/// Each format is fetched independently (CDN, 3 s timeout) and cached
/// to disk. On failure, falls back to the cached copy. Either field
/// may be `None` if offline with no cache.
///
/// When `KIGI_CHANGELOG_OFFLINE` is set (PTY / integration tests), skip
/// the CDN entirely and read only the disk cache so seeded fixtures win
/// deterministically without network races. Paths are re-resolved from
/// `$KIGI_SHARE_DIR` so harness-injected env always applies.
///
/// JSON is only cached after a successful parse to avoid poisoning the
/// disk cache with malformed content (the markdown cache is write-through
/// since it's consumed as raw text).
pub fn fetch(&self) -> Changelog {
// Always re-resolve from env so a caller holding an older manager
// (or OnceLock lag) still reads the live harness home.
Self::from_env_home().fetch_with(changelog_offline(), CHANGELOG_BASE)
}
/// Fetch using this manager's already-resolved cache paths, an explicit
/// offline flag, and an explicit CDN base.
///
/// Split out of [`fetch`] so unit tests can drive it against a temp home
/// without mutating process-global env (`KIGI_SHARE_DIR` /
/// `KIGI_CHANGELOG_OFFLINE`), which races across the parallel test
/// harness. Passing an unreachable `base` lets a test force a
/// deterministic CDN miss instead of depending on whether the sandbox
/// happens to block network. Production callers always go through
/// [`fetch`], so behaviour is unchanged.
fn fetch_with(&self, offline: bool, base: &str) -> Changelog {
if offline {
return Changelog {
markdown: read_cache(&self.md_cache),
entries: self.read_json_cache(),
};
}
let version = kigi_version::VERSION;
let md_url = format!("{}/{}.external.md", base, version);
// Fetch both formats in parallel (3s timeout each → 3s total, not 6s).
let mut markdown = None;
let mut entries = None;
std::thread::scope(|s| {
let md_handle = s.spawn(|| self.fetch_and_cache(&md_url, &self.md_cache));
let json_handle = s.spawn(|| self.fetch_json(base, version));
markdown = md_handle.join().ok().flatten();
entries = json_handle.join().ok().flatten();
});
// If CDN is unreachable (CI sandboxes, airplane mode), fall back to
// any on-disk seed under `$KIGI_SHARE_DIR` even when offline mode was not
// explicitly requested — keeps PTY/integration tests deterministic.
if markdown.is_none() {
markdown = read_cache(&self.md_cache);
}
if entries.is_none() {
entries = self.read_json_cache();
}
Changelog { markdown, entries }
}
/// Fetch and parse JSON changelog, caching only after successful parse.
fn fetch_json(&self, base: &str, version: &str) -> Option<Vec<ChangelogEntry>> {
let url = format!("{}/{}.external.json", base, version);
// Try remote first — only cache after successful parse.
if let Ok(raw) = fetch_blocking(&url)
&& !raw.trim().is_empty()
{
match serde_json::from_str::<Vec<ChangelogEntry>>(&raw) {
Ok(entries) => {
if let Err(e) = std::fs::write(&self.json_cache, &raw) {
tracing::debug!(error = %e, "JSON changelog cache write failed");
}
return Some(entries);
}
Err(e) => {
tracing::debug!(error = %e, "failed to parse JSON changelog from CDN");
}
}
}
self.read_json_cache()
}
fn read_json_cache(&self) -> Option<Vec<ChangelogEntry>> {
let cached = read_cache(&self.json_cache)?;
match serde_json::from_str(&cached) {
Ok(entries) => Some(entries),
Err(e) => {
tracing::debug!(error = %e, "failed to parse cached JSON changelog");
None
}
}
}
/// Shared fetch-and-cache: try remote (3 s timeout), cache on success,
/// fall back to disk cache on failure.
fn fetch_and_cache(&self, url: &str, cache_path: &std::path::Path) -> Option<String> {
if let Ok(content) = fetch_blocking(url)
&& !content.trim().is_empty()
{
if let Err(e) = std::fs::write(cache_path, &content) {
tracing::debug!(error = %e, path = %cache_path.display(), "cache write failed");
}
return Some(content);
}
read_cache(cache_path)
}
}
/// When set, `ChangelogManager::fetch` skips the CDN and only reads disk cache.
/// Used by PTY harness tests that seed `CHANGELOG.{md,json}` under a temp home.
fn changelog_offline() -> bool {
std::env::var_os("KIGI_CHANGELOG_OFFLINE").is_some_and(|v| !v.is_empty() && v != "0")
}
fn read_cache(path: &std::path::Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.filter(|c| !c.trim().is_empty())
}
/// Strip `**bold**` markers and backticks from a description string.
fn strip_markdown_inline(s: &str) -> String {
s.replace("**", "").replace('`', "")
}
/// Convert changelog entries to plain-text bullet strings.
///
/// Strips `**bold**` and backtick formatting from each description,
/// skips entries with empty descriptions (from tolerant deserialization),
/// and returns at most `max` entries.
pub fn bullets_from_entries(entries: &[ChangelogEntry], max: usize) -> Vec<String> {
entries
.iter()
.filter(|e| !e.description.is_empty())
.take(max)
.map(|e| strip_markdown_inline(&e.description))
.collect()
}
/// Blocking HTTP fetch. Callers (`std::thread::scope` threads) are already
/// off the tokio runtime, so no extra thread spawn is needed.
fn fetch_blocking(url: &str) -> anyhow::Result<String> {
let client = reqwest::blocking::Client::builder()
.timeout(FETCH_TIMEOUT)
.build()?;
let resp = client.get(url).send()?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
}
Ok(resp.text()?)
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a manager pointing at `home` directly, bypassing the global
/// `$KIGI_SHARE_DIR` env so tests never race the parallel harness.
fn manager_for(home: &std::path::Path) -> ChangelogManager {
ChangelogManager {
md_cache: home.join("CHANGELOG.md"),
json_cache: home.join("CHANGELOG.json"),
}
}
#[test]
fn offline_mode_reads_seeded_disk_cache_only() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("kigi-home");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("CHANGELOG.md"), "# seeded offline md\n").unwrap();
std::fs::write(
home.join("CHANGELOG.json"),
r#"[{"category":"features","description":"seeded entry","breaking_change":false}]"#,
)
.unwrap();
// Offline path: read only the seeded disk cache, no network.
let changelog = manager_for(&home).fetch_with(true, CHANGELOG_BASE);
assert_eq!(
changelog.markdown.as_deref(),
Some("# seeded offline md\n"),
"offline mode must return seeded markdown"
);
let entries = changelog.entries.expect("seeded json entries");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].description, "seeded entry");
}
#[test]
fn cdn_miss_falls_back_to_env_home_disk_cache() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("kigi-home-fallback");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("CHANGELOG.md"), "# fallback md\n").unwrap();
// Non-offline path with an unreachable CDN base: the remote fetch
// fails deterministically (no dependency on the sandbox blocking
// network), so the on-disk cache must win.
let changelog = manager_for(&home).fetch_with(false, "http://127.0.0.1:1");
assert_eq!(
changelog.markdown.as_deref(),
Some("# fallback md\n"),
"CDN miss must fall back to the seeded CHANGELOG.md"
);
}
#[test]
fn bullets_strips_markdown_and_respects_max() {
let entries = vec![
ChangelogEntry {
category: "features".into(),
description: "Added **dark mode** support".into(),
breaking_change: false,
},
ChangelogEntry {
category: "fixes".into(),
description: "Fixed `crash` on startup".into(),
breaking_change: false,
},
ChangelogEntry {
category: "performance".into(),
description: "Faster **rendering** of `code` blocks".into(),
breaking_change: false,
},
];
let bullets = bullets_from_entries(&entries, 2);
assert_eq!(bullets.len(), 2);
assert_eq!(bullets[0], "Added dark mode support");
assert_eq!(bullets[1], "Fixed crash on startup");
}
#[test]
fn bullets_skips_empty_descriptions() {
let entries = vec![
ChangelogEntry {
category: "features".into(),
description: "Good entry".into(),
breaking_change: false,
},
ChangelogEntry {
category: String::new(),
description: String::new(), // bad entry from tolerant deser
breaking_change: false,
},
ChangelogEntry {
category: "fixes".into(),
description: "Another good one".into(),
breaking_change: false,
},
];
let bullets = bullets_from_entries(&entries, 10);
assert_eq!(bullets, vec!["Good entry", "Another good one"]);
}
#[test]
fn tolerant_deserialization_partial_entry() {
// Missing description field → defaults to empty string, not a parse error
let json = r#"[{"category":"features"},{"description":"ok"}]"#;
let entries: Vec<ChangelogEntry> = serde_json::from_str(json).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].description, "");
assert_eq!(entries[1].category, "");
assert_eq!(entries[1].description, "ok");
}
}
@@ -1,4 +1,3 @@
pub mod changelog;
pub mod event_id; pub mod event_id;
pub mod kigi_home; pub mod kigi_home;
pub mod secure_file; pub mod secure_file;
@@ -1912,6 +1912,51 @@ impl Config {
.default(true) .default(true)
.resolve() .resolve()
} }
/// Graph mode (`/graph`) master switch. Default OFF — gray-released via
/// `KIGI_GRAPH=1` only (plan.md G0 gate). Graph mode additionally
/// requires the goal harness (nodes execute as goals), enforced at
/// availability time, not here.
pub(crate) fn resolve_graph(&self) -> Resolved<bool> {
BoolFlag::env("KIGI_GRAPH").default(false).resolve()
}
/// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`).
/// 1 = serial (G0-identical); clamped to [1, 8] — the coordinator has
/// no cap of its own, so this is the only brake on worker fan-out.
pub(crate) fn resolve_graph_concurrency(&self) -> u32 {
std::env::var("KIGI_GRAPH_CONCURRENCY")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(3)
.clamp(1, 8)
}
/// Graph topology optimizer master switch (`KIGI_GRAPH_OPTIMIZER`;
/// default on, `0` disables). Runs at plan boundaries only.
pub(crate) fn resolve_graph_optimizer_enabled(&self) -> bool {
!matches!(
std::env::var("KIGI_GRAPH_OPTIMIZER").ok().as_deref(),
Some("0") | Some("false")
)
}
/// Max replan passes per graph (`KIGI_GRAPH_REPLAN_CAP`); 0 turns
/// dynamic replanning off. Past the cap, discoveries drain to
/// history only — the graph must still converge. Clamped to [0, 10].
pub(crate) fn resolve_graph_replan_cap(&self) -> u32 {
std::env::var("KIGI_GRAPH_REPLAN_CAP")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(3)
.clamp(0, 10)
}
/// Max worker↔verifier rounds per parallel graph node
/// (`KIGI_GRAPH_NODE_ROUNDS`); exhausting them fails the node.
/// Clamped to [1, 8].
pub(crate) fn resolve_graph_node_rounds(&self) -> u32 {
std::env::var("KIGI_GRAPH_NODE_ROUNDS")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(3)
.clamp(1, 8)
}
/// Classifier, planner, and summary all default to goal mode itself: when /// Classifier, planner, and summary all default to goal mode itself: when
/// `/goal` is on they are on unless config/env/remote says otherwise. /// `/goal` is on they are on unless config/env/remote says otherwise.
/// `goal_enabled` is the session's already-resolved master switch (the same /// `goal_enabled` is the session's already-resolved master switch (the same
@@ -754,6 +754,7 @@ impl acp::Agent for MvpAgent {
persisted_signals: None, persisted_signals: None,
persisted_plan_mode: None, persisted_plan_mode: None,
persisted_goal_mode: None, persisted_goal_mode: None,
persisted_graph_mode: None,
persisted_announcement_state: None, persisted_announcement_state: None,
session_meta: arguments.meta.as_ref(), session_meta: arguments.meta.as_ref(),
model_agent_type: model_agent_type.as_deref(), model_agent_type: model_agent_type.as_deref(),
@@ -985,6 +986,7 @@ impl acp::Agent for MvpAgent {
signals: persisted_signals, signals: persisted_signals,
announcement_state: persisted_announcement_state, announcement_state: persisted_announcement_state,
goal_mode_state: _persisted_goal_mode, goal_mode_state: _persisted_goal_mode,
graph_mode_state: _persisted_graph_mode,
} = persistence_info; } = persistence_info;
let restored_awaiting_plan_approval = persisted_plan_mode let restored_awaiting_plan_approval = persisted_plan_mode
.as_ref() .as_ref()
@@ -1207,6 +1209,7 @@ impl acp::Agent for MvpAgent {
persisted_signals, persisted_signals,
persisted_plan_mode, persisted_plan_mode,
persisted_goal_mode: _persisted_goal_mode, persisted_goal_mode: _persisted_goal_mode,
persisted_graph_mode: _persisted_graph_mode,
persisted_announcement_state, persisted_announcement_state,
session_meta: request_meta.as_ref(), session_meta: request_meta.as_ref(),
model_agent_type: persisted_agent_name.as_deref(), model_agent_type: persisted_agent_name.as_deref(),
@@ -310,8 +310,13 @@ impl MvpAgent {
pub(crate) fn command_availability( pub(crate) fn command_availability(
&self, &self,
) -> crate::session::slash_commands::CommandAvailability { ) -> crate::session::slash_commands::CommandAvailability {
let goal = self.cfg.borrow().resolve_goal().value;
crate::session::slash_commands::CommandAvailability { crate::session::slash_commands::CommandAvailability {
goal: self.cfg.borrow().resolve_goal().value, goal,
// Same convention as /goal: the flag is known at initialize
// time, so advertise pre-session; the in-session path
// re-checks the live toolset.
graph: goal && self.cfg.borrow().resolve_graph().value,
..crate::session::slash_commands::CommandAvailability::default() ..crate::session::slash_commands::CommandAvailability::default()
} }
} }
@@ -1677,6 +1682,7 @@ impl MvpAgent {
persisted_signals, persisted_signals,
persisted_plan_mode, persisted_plan_mode,
persisted_goal_mode, persisted_goal_mode,
persisted_graph_mode,
persisted_announcement_state, persisted_announcement_state,
session_meta, session_meta,
model_agent_type, model_agent_type,
@@ -2111,6 +2117,7 @@ impl MvpAgent {
let web_fetch_config = self.prepare_web_fetch_config(); let web_fetch_config = self.prepare_web_fetch_config();
let write_file_enabled = self.cfg.borrow().resolve_write_file().value; let write_file_enabled = self.cfg.borrow().resolve_write_file().value;
let goal_enabled = self.cfg.borrow().resolve_goal().value; let goal_enabled = self.cfg.borrow().resolve_goal().value;
let graph_enabled = self.cfg.borrow().resolve_graph().value;
let subagents_enabled = self.cfg.borrow().subagents_enabled; let subagents_enabled = self.cfg.borrow().subagents_enabled;
let ask_user_question_enabled = parse_ask_user_question_from_meta(session_meta) let ask_user_question_enabled = parse_ask_user_question_from_meta(session_meta)
.unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value); .unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value);
@@ -2314,6 +2321,7 @@ impl MvpAgent {
persisted_signals, persisted_signals,
persisted_plan_mode, persisted_plan_mode,
persisted_goal_mode, persisted_goal_mode,
persisted_graph_mode,
persisted_announcement_state, persisted_announcement_state,
self.memory_config.clone(), self.memory_config.clone(),
feedback_flags, feedback_flags,
@@ -2328,6 +2336,7 @@ impl MvpAgent {
app_builder_deployer_config, app_builder_deployer_config,
write_file_enabled, write_file_enabled,
goal_enabled, goal_enabled,
graph_enabled,
subagents_enabled, subagents_enabled,
ask_user_question_enabled, ask_user_question_enabled,
client_hooks, client_hooks,
@@ -120,6 +120,7 @@ pub(crate) struct SessionSpawnOptions<'a> {
pub persisted_signals: Option<crate::session::signals::SessionSignals>, pub persisted_signals: Option<crate::session::signals::SessionSignals>,
pub persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>, pub persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
pub persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>, pub persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
pub persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
pub persisted_announcement_state: Option< pub persisted_announcement_state: Option<
crate::session::announcement_state::AnnouncementState, crate::session::announcement_state::AnnouncementState,
>, >,
@@ -257,6 +258,7 @@ pub(crate) fn chat_session_spawn_options<'a>(
persisted_signals: None, persisted_signals: None,
persisted_plan_mode: None, persisted_plan_mode: None,
persisted_goal_mode: None, persisted_goal_mode: None,
persisted_graph_mode: None,
persisted_announcement_state: None, persisted_announcement_state: None,
session_meta, session_meta,
model_agent_type, model_agent_type,
@@ -1036,6 +1036,7 @@ pub(crate) async fn handle_subagent_request(
None, None,
None, None,
None, None,
None,
if verbatim_mirror_fork { if verbatim_mirror_fork {
None None
} else if let Some(scope) = agent_memory_scope { } else if let Some(scope) = agent_memory_scope {
@@ -1069,6 +1070,8 @@ pub(crate) async fn handle_subagent_request(
ctx.app_builder_deployer_config.clone(), ctx.app_builder_deployer_config.clone(),
ctx.write_file_enabled, ctx.write_file_enabled,
ctx.goal_enabled, ctx.goal_enabled,
// Graph mode is a depth-0 harness; child sessions never drive it.
false,
true, true,
ctx.ask_user_question_enabled, ctx.ask_user_question_enabled,
ctx.client_hooks.clone(), ctx.client_hooks.clone(),
+2 -2
View File
@@ -116,8 +116,8 @@ pub fn extract_bundled_files(kigi_home: &std::path::Path) {
let _ = std::fs::create_dir_all(kigi_home); let _ = std::fs::create_dir_all(kigi_home);
// Clean up cached changelog files from previous version so // Clean up changelog caches written by the removed changelog feature
// /release-notes fetches fresh content for the new version. // (kigi <= 0.1.0 cached CDN release notes in the kigi home).
for stale in &["CHANGELOG.json", "CHANGELOG.md"] { for stale in &["CHANGELOG.json", "CHANGELOG.md"] {
let _ = std::fs::remove_file(kigi_home.join(stale)); let _ = std::fs::remove_file(kigi_home.join(stale));
} }
@@ -897,6 +897,36 @@ pub enum SessionUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<PromptUsage>, usage: Option<PromptUsage>,
}, },
/// Graph mode (`/graph`) progress for the pager's status chip.
/// Wire tag `graph_updated`; `status: "cleared"` tells the pager to
/// drop its graph state (same sentinel convention as `GoalUpdated`).
/// Old pagers degrade to [`Self::Unknown`] silently.
GraphUpdated {
graph_id: String,
objective: String,
/// Goal-status vocabulary (`active`, paused family,
/// `budget_limited`, `complete`) plus `cleared`.
status: String,
/// `idle` | `planning` | `executing`.
phase: String,
plan_version: u32,
total_nodes: u32,
achieved_nodes: u32,
failed_nodes: u32,
running_nodes: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
current_node: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
current_node_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
token_budget: Option<i64>,
#[serde(default)]
tokens_spent: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pause_message: Option<String>,
},
/// Catch-all for unrecognized session update types. /// Catch-all for unrecognized session update types.
/// Allows forward/backward compatibility when variants are added or removed. /// Allows forward/backward compatibility when variants are added or removed.
/// All fields from the unrecognized variant are discarded during deserialization. /// All fields from the unrecognized variant are discarded during deserialization.
@@ -2293,3 +2323,88 @@ mod tests {
assert!(serde_json::from_str::<SessionUpdate>(missing_stop_reason).is_err()); assert!(serde_json::from_str::<SessionUpdate>(missing_stop_reason).is_err());
} }
} }
#[cfg(test)]
mod graph_updated_wire_tests {
use super::*;
fn full_graph_updated() -> SessionUpdate {
SessionUpdate::GraphUpdated {
graph_id: "g-1".into(),
objective: "ship it".into(),
status: "active".into(),
phase: "executing".into(),
plan_version: 2,
total_nodes: 5,
achieved_nodes: 2,
failed_nodes: 1,
running_nodes: 1,
current_node: Some("gn-abc".into()),
current_node_title: Some("Node C".into()),
token_budget: Some(10_000),
tokens_spent: 4_200,
last_event: Some("node_achieved".into()),
pause_message: None,
}
}
#[test]
fn graph_updated_round_trips_with_snake_case_tag() {
let update = full_graph_updated();
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["sessionUpdate"], "graph_updated");
assert_eq!(json["achieved_nodes"], 2);
assert_eq!(json["current_node_title"], "Node C");
// Omitted optionals must not serialize at all.
assert!(json.get("pause_message").is_none());
let back: SessionUpdate = serde_json::from_value(json).unwrap();
assert_eq!(back, update);
}
#[test]
fn graph_updated_minimal_payload_fills_defaults() {
// Only the required fields on the wire: every optional absent,
// `tokens_spent` relies on #[serde(default)].
let json = serde_json::json!({
"sessionUpdate": "graph_updated",
"graph_id": "g-2",
"objective": "o",
"status": "cleared",
"phase": "idle",
"plan_version": 0,
"total_nodes": 0,
"achieved_nodes": 0,
"failed_nodes": 0,
"running_nodes": 0,
});
let update: SessionUpdate = serde_json::from_value(json).unwrap();
match update {
SessionUpdate::GraphUpdated {
status,
tokens_spent,
current_node,
token_budget,
pause_message,
..
} => {
assert_eq!(status, "cleared");
assert_eq!(tokens_spent, 0, "#[serde(default)] must backfill");
assert!(current_node.is_none());
assert!(token_budget.is_none());
assert!(pause_message.is_none());
}
other => panic!("expected GraphUpdated, got {other:?}"),
}
}
/// An OLD pager (this enum before the variant existed) must degrade
/// a graph_updated payload to `Unknown` — pinned by feeding an
/// unknown-tag payload through today's enum, which uses the same
/// #[serde(other)] mechanism.
#[test]
fn unknown_tags_still_degrade_gracefully() {
let json = serde_json::json!({ "sessionUpdate": "graph_updated_v99", "x": 1 });
let update: SessionUpdate = serde_json::from_value(json).unwrap();
assert_eq!(update, SessionUpdate::Unknown);
}
}
@@ -93,6 +93,14 @@ pub(crate) use types::*;
pub use types::{TodoGateDecision, TodoGateReason}; pub use types::{TodoGateDecision, TodoGateReason};
#[path = "acp_session_impl/goal.rs"] #[path = "acp_session_impl/goal.rs"]
mod goal; mod goal;
#[path = "acp_session_impl/graph.rs"]
mod graph;
#[path = "acp_session_impl/graph_optimize.rs"]
mod graph_optimize;
#[path = "acp_session_impl/graph_replan.rs"]
mod graph_replan;
#[path = "acp_session_impl/graph_workers.rs"]
mod graph_workers;
#[path = "acp_session_impl/interjection.rs"] #[path = "acp_session_impl/interjection.rs"]
mod interjection; mod interjection;
#[path = "acp_session_impl/tool_calls.rs"] #[path = "acp_session_impl/tool_calls.rs"]
@@ -589,6 +597,34 @@ pub(crate) struct SessionActor {
/// Goal mode orchestration tracker. Session-scoped state for the /// Goal mode orchestration tracker. Session-scoped state for the
/// Design-Execute-Verify loop. Modeled after `plan_mode` above. /// Design-Execute-Verify loop. Modeled after `plan_mode` above.
pub(crate) goal_tracker: Arc<parking_lot::Mutex<crate::session::goal_tracker::GoalTracker>>, pub(crate) goal_tracker: Arc<parking_lot::Mutex<crate::session::goal_tracker::GoalTracker>>,
/// Whether graph mode (`/graph`) is enabled for this session (feature
/// flag `KIGI_GRAPH`). Availability additionally requires the goal
/// harness — graph nodes execute as goals.
pub(crate) graph_enabled: bool,
/// Graph mode orchestration tracker: the deterministic DAG scheduler
/// layered over the goal engine. Modeled after `goal_tracker` above;
/// all graph state logic lives in `graph_tracker.rs`.
pub(crate) graph_tracker: Arc<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
/// Max graph nodes running concurrently (1 = serial G0 behavior).
/// Cached at actor construction from `resolve_graph_concurrency`.
pub(crate) graph_concurrency: u32,
/// Max worker↔verifier rounds per parallel graph node before the
/// node fails. Cached at actor construction.
pub(crate) graph_node_rounds: u32,
/// Max replan passes per graph (0 = replanning off). Cached at
/// actor construction.
pub(crate) graph_replan_cap: u32,
/// Topology optimizer switch (plan-boundary passes; shares the
/// replan cap). Cached at actor construction.
pub(crate) graph_optimizer_enabled: bool,
/// `.kigi` dir at the git root, when the session cwd is in a git
/// repo — home of the project-level shared graph projection.
pub(crate) graph_project_dir: Option<std::path::PathBuf>,
/// Held single-writer lock on the project graph. `Some` while this
/// session owns the graph (created or resumed it); dropped on
/// `/graph clear`.
pub(crate) graph_project_lock:
std::cell::RefCell<Option<crate::session::graph_project::ProjectGraphLock>>,
/// `task_id`s of background tasks (and monitors) that originated during /// `task_id`s of background tasks (and monitors) that originated during
/// the goal turn — either spawned by the goal model itself or reparented /// the goal turn — either spawned by the goal model itself or reparented
/// from a harness verifier/planner subagent on its exit. Their late /// from a harness verifier/planner subagent on its exit. Their late
@@ -985,6 +1021,9 @@ impl SessionActor {
hooks: self.hook_registry.borrow().is_some(), hooks: self.hook_registry.borrow().is_some(),
plugins: self.plugin_registry.borrow().is_some(), plugins: self.plugin_registry.borrow().is_some(),
goal, goal,
// Graph rides the goal harness: nodes execute as goals, so
// `/graph` is only real when `/goal` is.
graph: self.graph_enabled && goal,
} }
} }
/// Names of every tool registered with the session's tool bridge. /// Names of every tool registered with the session's tool bridge.
@@ -1423,6 +1462,9 @@ mod goal_strategist_e2e_tests;
#[path = "acp_session_tests/goal/goal_summarizer_e2e_tests.rs"] #[path = "acp_session_tests/goal/goal_summarizer_e2e_tests.rs"]
mod goal_summarizer_e2e_tests; mod goal_summarizer_e2e_tests;
#[cfg(test)] #[cfg(test)]
#[path = "acp_session_tests/graph/graph_e2e_tests.rs"]
mod graph_e2e_tests;
#[cfg(test)]
#[path = "acp_session_tests/idle_resume_tests.rs"] #[path = "acp_session_tests/idle_resume_tests.rs"]
mod idle_resume_tests; mod idle_resume_tests;
#[cfg(test)] #[cfg(test)]
@@ -1904,6 +1904,29 @@ impl SessionActor {
tokens_used, tokens_used,
finished_marginal, finished_marginal,
); );
// Graph cascade: node goals are armed with the REMAINING graph
// budget, so a node-level budget trip is the graph-level trip.
if self.graph_harness_enabled() && self.graph_tracker.lock().is_active() {
tracing::warn!("graph: node goal budget trip cascades to graph BudgetLimited");
{
let mut tracker = self.graph_tracker.lock();
// Budget integrity: charge the tripped node's partial burn
// BEFORE budget_limit clears current_node — otherwise the
// top-up arithmetic runs on an under-counted ledger.
if let Some(node_id) = tracker.current_node_id().map(str::to_owned) {
tracker.charge_node_tokens(&node_id, tokens_used);
}
tracker.budget_limit();
}
self.persist_graph_state();
self.send_slash_command_output(&format!(
"Graph token budget reached ({tokens_used} tokens this node) — graph \
stopped. Top up with /graph resume --budget <tokens>, or /graph clear \
to abandon."
))
.await;
return true;
}
self.send_slash_command_output(&format!( self.send_slash_command_output(&format!(
"Goal token budget reached ({tokens_used} of {budget} tokens) — goal \ "Goal token budget reached ({tokens_used} of {budget} tokens) — goal \
stopped. Use /goal clear, then /goal <objective> to start a new one." stopped. Use /goal clear, then /goal <objective> to start a new one."
@@ -2265,18 +2288,21 @@ impl SessionActor {
message: Option<String>, message: Option<String>,
) -> bool { ) -> bool {
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64; let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
{ let graph_message = message.clone();
let goal_paused = {
let mut tracker = self.goal_tracker.lock(); let mut tracker = self.goal_tracker.lock();
if tracker.status() != Some(crate::session::goal_tracker::GoalStatus::Active) { if tracker.status() == Some(crate::session::goal_tracker::GoalStatus::Active) {
return false; // Active is guaranteed here, so the transition succeeds.
}
// The early-return above guarantees `Active`, so the pause
// transition always succeeds here.
match message { match message {
Some(msg) => tracker.pause_with_message(reason, msg), Some(msg) => tracker.pause_with_message(reason, msg),
None => tracker.pause(reason), None => tracker.pause(reason),
}; };
true
} else {
false
} }
};
if goal_paused {
self.clear_pending_classifier_completions(); self.clear_pending_classifier_completions();
let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens); let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens);
let notify = self.goal_notify_sender(); let notify = self.goal_notify_sender();
@@ -2288,7 +2314,31 @@ impl SessionActor {
self.emit_event(crate::session::events::Event::GoalAutoPaused { self.emit_event(crate::session::events::Event::GoalAutoPaused {
reason: reason.into(), reason: reason.into(),
}); });
true }
// Graph cascade chokepoint: every goal auto-pause path funnels
// through here. It runs REGARDLESS of whether a goal pause
// applied — a cancel can land while the graph is Active with no
// node goal in the engine (during graph planning, or on the node
// boundary between engine reset and goal creation), and the
// graph must still lose its self-driving status. `pause` is
// Active-only, so double cascades are idempotent.
if self.graph_harness_enabled() && self.graph_tracker.lock().is_active() {
let node = self
.graph_tracker
.lock()
.current_node_id()
.map(str::to_owned);
tracing::info!(reason = ?reason, node = ?node, goal_paused, "graph: cascading pause to graph");
let detail = match (&node, &graph_message) {
(Some(n), Some(msg)) => format!("Node {n} paused: {msg}"),
(Some(n), None) => format!("Node {n} paused ({reason:?})"),
(None, Some(msg)) => format!("Paused with no node goal in flight: {msg}"),
(None, None) => format!("Paused with no node goal in flight ({reason:?})"),
};
self.graph_tracker.lock().pause_with_message(reason, detail);
self.persist_graph_state();
}
goal_paused
} }
/// Match the last assistant message text (via /// Match the last assistant message text (via
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
//! Topology optimizer (G6): a plan-boundary review pass that may issue
//! a RESTRICTED set of graph edits — remove false deps (restoring
//! parallelism), reorder pending priority, merge tiny nodes, split
//! oversized ones — over Waiting/Ready nodes only.
//!
//! The optimizer changes GRAPH DATA only; the executor stays pure
//! deterministic Rust. It fires ① right after initial planning and
//! ② at each replan boundary (piggybacked), never mid-execution.
//! Applied (non-empty) passes bump `plan_version`, freeze a baseline,
//! and consume a slot of the SHARED replan cap; an explicit `[]` is a
//! respected no-op consuming nothing. Failure degrades — the current
//! graph keeps running. `KIGI_GRAPH_OPTIMIZER=0` disables entirely.
use std::sync::Arc;
use super::super::goal_planner::{ChannelSpawner, GoalPlannerSpawner};
use super::super::graph_plan;
use super::super::graph_planner::{ArtifactPassSpec, run_graph_artifact_pass};
use super::SessionActor;
const OPTIMIZER_PROMPT_TEMPLATE: &str = include_str!("../templates/graph_optimizer_prompt.md");
impl SessionActor {
/// One optimizer pass at a plan boundary. No-op when disabled, when
/// the shared cap is exhausted, or when the graph is not Active.
pub(super) async fn maybe_optimize_graph(&self) {
if !self.graph_optimizer_enabled {
return;
}
let (replan_runs, current_graph, history_text, graph_file, next_version) = {
let tracker = self.graph_tracker.lock();
let Some(state) = tracker.snapshot() else {
return;
};
if state.status != crate::session::goal_tracker::GoalStatus::Active {
return;
}
let compact: Vec<serde_json::Value> = state
.nodes
.iter()
.map(|n| {
serde_json::json!({
"id": n.id,
"title": n.title,
"spec": n.spec,
"status": format!("{:?}", n.status),
"deps": n.deps.iter().map(|d| d.on.clone()).collect::<Vec<_>>(),
})
})
.collect();
let history_text = state
.history
.iter()
.rev()
.take(12)
.map(|h| {
format!(
"- {:?} {} {}",
h.event,
h.node_id.as_deref().unwrap_or("-"),
h.detail.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("\n");
(
state.replan_runs,
serde_json::to_string(&compact).unwrap_or_default(),
history_text,
tracker
.artifacts_dir()
.join(format!("optimize.v{}.json", state.plan_version + 1)),
state.plan_version + 1,
)
};
if self.graph_replan_cap == 0 || replan_runs >= self.graph_replan_cap {
tracing::info!(
replan_runs,
cap = self.graph_replan_cap,
"graph optimizer: shared cap exhausted; skipping pass"
);
return;
}
let Some(event_tx) = self.tool_context.subagent_event_tx.clone() else {
return;
};
let objective = self
.graph_tracker
.lock()
.objective()
.map(str::to_owned)
.unwrap_or_default();
let parent_prompt_id = self
.current_prompt_id
.lock()
.expect("current_prompt_id mutex poisoned")
.clone();
let spawner: Arc<dyn GoalPlannerSpawner> = Arc::new(ChannelSpawner {
event_tx,
parent_session_id: self.session_id_string(),
parent_prompt_id,
cwd: Some(self.tool_context.cwd.as_str().to_owned()),
role_override: Default::default(),
events: Some(self.events.writer()),
});
let tool_names = self.resolve_inherit_role_tool_names().await;
let sections = format!(
"OBJECTIVE:\n{objective}\n\nCURRENT GRAPH:\n{current_graph}\n\n\
EXECUTION HISTORY:\n{history_text}\n"
);
tracing::info!(next_version, "graph optimizer: firing");
let json = match run_graph_artifact_pass(
spawner,
ArtifactPassSpec {
template: OPTIMIZER_PROMPT_TEMPLATE,
sections: &sections,
graph_file: &graph_file,
tool_names: &tool_names,
role: "graph optimizer",
},
)
.await
{
Ok(json) => json,
Err(reason) => {
// Degrade: an enhancement pass never blocks the graph.
tracing::warn!(%reason, "graph optimizer: pass failed; keeping current plan");
return;
}
};
let existing = self
.graph_tracker
.lock()
.snapshot()
.map(|s| s.nodes.clone())
.unwrap_or_default();
match graph_plan::apply_optimization(&existing, &json) {
Ok(None) => {
tracing::info!("graph optimizer: no ops (already good)");
}
Ok(Some(optimized)) => {
let n_before = existing.len();
let n_after = optimized.len();
self.graph_tracker.lock().install_optimized_nodes(optimized);
let all_nodes = self
.graph_tracker
.lock()
.snapshot()
.map(|s| s.nodes.clone())
.unwrap_or_default();
if let Err(err) = self.write_graph_baseline(&all_nodes).await {
tracing::warn!(%err, "graph optimizer: baseline write failed (audit gap only)");
}
self.persist_graph_state();
tracing::info!(
next_version,
n_before,
n_after,
"graph optimizer: plan optimized"
);
self.send_slash_command_output(&format!(
"Graph optimized (v{next_version}): {n_before} → {n_after} node(s)."
))
.await;
}
Err(err) => {
tracing::warn!(%err, "graph optimizer: ops rejected; keeping current plan");
}
}
}
}
@@ -0,0 +1,236 @@
//! Dynamic replan (G3): fold `DISCOVERED:` items surfaced during node
//! execution into the graph at dispatch boundaries.
//!
//! SGH version discipline: the running plan is immutable inside a
//! version — a replan appends new nodes (never edits existing ones),
//! bumps `plan_version`, and freezes a new immutable baseline. The pass
//! is BOUNDED by `KIGI_GRAPH_REPLAN_CAP` (default 3, 0 = off): past the
//! cap, discoveries drain to history only, so the graph always
//! converges. Replan failure DEGRADES (discoveries kept in history, the
//! graph keeps running) — unlike initial planning, a working graph is
//! never paused because an enhancement pass failed.
use std::sync::Arc;
use super::super::goal_planner::{ChannelSpawner, GoalPlannerSpawner};
use super::super::graph_planner::{
GRAPH_REPLANNER_SUBAGENT_DESCRIPTION, GraphPlannerOutcome, GraphReplannerInputs,
run_graph_replanner,
};
use super::SessionActor;
impl SessionActor {
/// Replan boundary, called at the top of every `drive_graph`
/// iteration. No-op without pending discoveries.
pub(super) async fn maybe_replan_graph(&self) {
let (pending, replan_runs) = {
let tracker = self.graph_tracker.lock();
let Some(state) = tracker.snapshot() else {
return;
};
(state.pending_discoveries.clone(), state.replan_runs)
};
if pending.is_empty() {
return;
}
// Budget first: a budget-dead graph must not spend two replanner
// runs right before the dispatch loop trips BudgetLimited. The
// discoveries STAY QUEUED (persisted) — a later
// `/graph resume --budget` top-up re-enters and replans with
// budget actually available.
if self.graph_tracker.lock().remaining_budget() == Some(0) {
tracing::info!("graph replan: budget exhausted; keeping discoveries queued");
return;
}
let final_achieved = self
.graph_tracker
.lock()
.node(super::super::graph_tracker::FINAL_NODE_ID)
.is_some_and(|n| n.status == super::super::graph_tracker::NodeStatus::Achieved);
if final_achieved {
// The whole-objective gate already passed; late discoveries
// (typically from the final verification itself) are
// advisory — appending nodes now would ship work the
// terminal gate never re-verified.
let n = self.graph_tracker.lock().drain_discoveries_to_history();
self.persist_graph_state();
tracing::info!(
drained = n,
"graph replan: final already achieved; history only"
);
return;
}
if self.graph_replan_cap == 0 {
// Feature off: quiet drain (history keeps the audit trail).
let n = self.graph_tracker.lock().drain_discoveries_to_history();
self.persist_graph_state();
tracing::info!(drained = n, "graph replan: disabled (cap 0); history only");
return;
}
if replan_runs >= self.graph_replan_cap {
let n = self.graph_tracker.lock().drain_discoveries_to_history();
self.persist_graph_state();
tracing::warn!(
drained = n,
replan_runs,
cap = self.graph_replan_cap,
"graph replan: cap exhausted; discoveries recorded in history only"
);
self.send_slash_command_output(&format!(
"Graph replan cap reached ({replan_runs}/{}); {n} discover{} recorded in \
history only the graph will converge on the current plan.",
self.graph_replan_cap,
if n == 1 { "y" } else { "ies" },
))
.await;
return;
}
let Some(event_tx) = self.tool_context.subagent_event_tx.clone() else {
tracing::warn!("graph replan: no subagent coordinator; keeping discoveries queued");
return;
};
let (existing, objective, current_graph, discoveries_text, graph_file, next_version) = {
let tracker = self.graph_tracker.lock();
let Some(state) = tracker.snapshot() else {
return;
};
let compact: Vec<serde_json::Value> = state
.nodes
.iter()
.map(|n| {
serde_json::json!({
"id": n.id,
"title": n.title,
"status": format!("{:?}", n.status),
"deps": n.deps.iter().map(|d| d.on.clone()).collect::<Vec<_>>(),
})
})
.collect();
let discoveries_text = pending
.iter()
.map(|d| format!("- (from {}) {}", d.from_node, d.description))
.collect::<Vec<_>>()
.join("\n");
(
state.nodes.clone(),
state.objective.clone(),
serde_json::to_string(&compact).unwrap_or_default(),
discoveries_text,
tracker
.artifacts_dir()
.join(format!("replan.v{}.json", state.plan_version + 1)),
state.plan_version + 1,
)
};
let parent_prompt_id = self
.current_prompt_id
.lock()
.expect("current_prompt_id mutex poisoned")
.clone();
let spawner: Arc<dyn GoalPlannerSpawner> = Arc::new(ChannelSpawner {
event_tx,
parent_session_id: self.session_id_string(),
parent_prompt_id,
cwd: Some(self.tool_context.cwd.as_str().to_owned()),
role_override: Default::default(),
events: Some(self.events.writer()),
});
let tool_names = self.resolve_inherit_role_tool_names().await;
let mut feedback = String::new();
for attempt in 1..=2u32 {
tracing::info!(
attempt,
next_version,
role = GRAPH_REPLANNER_SUBAGENT_DESCRIPTION,
pending = pending.len(),
"graph replan: firing"
);
match run_graph_replanner(
spawner.clone(),
&existing,
GraphReplannerInputs {
objective: &objective,
current_graph: &current_graph,
discoveries: &discoveries_text,
feedback: &feedback,
graph_file: &graph_file,
tool_names: &tool_names,
inherit_tool_names: &tool_names,
},
)
.await
{
GraphPlannerOutcome::Planned(appendix) if appendix.is_empty() => {
// Escape hatch: everything already covered. The pass
// still counts against the cap.
tracing::info!("graph replan: empty appendix (already covered)");
{
let mut tracker = self.graph_tracker.lock();
tracker.drain_discoveries_to_history();
if let Some(state) = tracker.snapshot_mut() {
state.replan_runs += 1;
}
}
self.persist_graph_state();
return;
}
GraphPlannerOutcome::Planned(appendix) => {
let added = appendix.len();
self.graph_tracker.lock().append_replan_nodes(appendix);
// Freeze the new version's immutable baseline (full
// node set post-append; create_new keeps v{N-1}
// byte-identical forever).
let all_nodes = self
.graph_tracker
.lock()
.snapshot()
.map(|s| s.nodes.clone())
.unwrap_or_default();
if let Err(err) = self.write_graph_baseline(&all_nodes).await {
tracing::warn!(%err, "graph replan: baseline write failed (audit gap only)");
}
self.persist_graph_state();
tracing::info!(added, next_version, "graph replan: appendix installed");
self.send_slash_command_output(&format!(
"Graph replanned (v{next_version}): {added} node(s) added from \
discovered work."
))
.await;
// Plan-boundary optimizer pass ② (piggybacked on the
// replan version boundary).
self.maybe_optimize_graph().await;
return;
}
GraphPlannerOutcome::Invalid { reason } if attempt == 1 => {
tracing::warn!(%reason, "graph replan: invalid appendix; retrying with feedback");
feedback = format!(
"Your previous replan JSON failed validation:\n{reason}\n\
Rewrite the file fixing exactly this."
);
}
GraphPlannerOutcome::Invalid { reason }
| GraphPlannerOutcome::FailClosed { reason } => {
// Degrade, never pause a working graph for a failed
// enhancement pass. The run still counts.
tracing::warn!(%reason, "graph replan: failed; draining discoveries to history");
{
let mut tracker = self.graph_tracker.lock();
tracker.drain_discoveries_to_history();
if let Some(state) = tracker.snapshot_mut() {
state.replan_runs += 1;
}
}
self.persist_graph_state();
self.send_slash_command_output(&format!(
"Graph replan failed ({reason}); discovered work recorded in \
history only."
))
.await;
return;
}
}
}
}
}
@@ -0,0 +1,979 @@
//! Parallel graph-node execution: worker/verifier subagent pairs.
//!
//! In parallel mode (`KIGI_GRAPH_CONCURRENCY > 1` with ≥2 `Ready`
//! nodes) a node does NOT run on the session goal engine — it runs as a
//! harness-internal `general-purpose` subagent (the implementer toolset)
//! in its OWN git worktree, adversarially checked by a read-only
//! verifier subagent, with a bounded worker↔verifier round loop
//! (`graph_node_rounds`). Achieved nodes merge back into the main tree
//! SEQUENTIALLY via `kigi_workspace`'s 3-way `apply_worktree`; a merge
//! conflict fails the node (its dependents block; other chains
//! continue). The terminal `gn-final` node always runs serially on the
//! full goal engine because it depends on every other node.
//!
//! Known ceiling: a worker round that outlives the foreground subagent
//! await budget (default 600s) is cancelled and counted as a failed
//! round with an explicit gap; the next round resumes the same child
//! session. Fetching results from auto-backgrounded children would need
//! completed-store plumbing — deferred until real usage demands it.
use std::sync::Arc;
use kigi_tools::implementations::kigi::task::types::{
SubagentEvent, SubagentRequest, SubagentRuntimeOverrides,
};
use super::SessionActor;
const WORKER_PROMPT_TEMPLATE: &str = include_str!("../templates/graph_node_worker_prompt.md");
const VERIFIER_PROMPT_TEMPLATE: &str = include_str!("../templates/graph_node_verifier_prompt.md");
// Terminal-contract parsing
/// The worker's parsed claim, from the trailing `NODE_RESULT:` line.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum WorkerClaim {
Done { summary: String },
Blocked { reason: String },
Unparseable,
}
/// Drop ``` fenced code blocks so a QUOTED marker (the templates
/// themselves contain fenced `NODE_RESULT:`/`NODE_VERDICT:` examples a
/// child may echo) can never be parsed as the real terminal line.
fn strip_fenced_blocks(output: &str) -> String {
let mut kept = String::with_capacity(output.len());
let mut in_fence = false;
for line in output.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if !in_fence {
kept.push_str(line);
kept.push('\n');
}
}
kept
}
/// The LAST line that STARTS with `marker` (line-anchored — a
/// mid-sentence mention never matches), plus everything after it.
fn last_marker_line(output: &str, marker: &str) -> Option<(String, String)> {
let lines: Vec<&str> = output.lines().collect();
let idx = lines
.iter()
.rposition(|l| l.trim_start().trim_start_matches('`').starts_with(marker))?;
let value = lines[idx]
.trim_start()
.trim_start_matches('`')
.trim_start_matches(marker)
.trim()
.trim_matches('`')
.to_owned();
let tail = lines[idx + 1..].join("\n").trim().to_owned();
Some((value, tail))
}
/// Parse the last line-anchored `NODE_RESULT:` marker outside fenced
/// blocks; text after the marker line is the summary/reason.
/// Fail-closed: no marker ⇒ `Unparseable`.
pub(crate) fn parse_worker_claim(output: &str) -> WorkerClaim {
let stripped = strip_fenced_blocks(output);
let Some((value, tail)) = last_marker_line(&stripped, "NODE_RESULT:") else {
return WorkerClaim::Unparseable;
};
match value.as_str() {
"done" => WorkerClaim::Done { summary: tail },
"blocked" => WorkerClaim::Blocked {
reason: if tail.is_empty() {
"no reason given".to_owned()
} else {
tail
},
},
_ => WorkerClaim::Unparseable,
}
}
/// The verifier's parsed verdict, from the trailing `NODE_VERDICT:` line.
/// Fail-closed: anything unparseable is `NotAchieved` with that fact as
/// the gap.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum NodeVerdict {
Achieved,
NotAchieved { gaps: Vec<String> },
}
pub(crate) fn parse_node_verdict(output: &str) -> NodeVerdict {
let stripped = strip_fenced_blocks(output);
let Some((value, tail)) = last_marker_line(&stripped, "NODE_VERDICT:") else {
return NodeVerdict::NotAchieved {
gaps: vec!["verifier response lacked a NODE_VERDICT line".to_owned()],
};
};
match value.as_str() {
"achieved" => NodeVerdict::Achieved,
"not_achieved" => {
let gaps: Vec<String> = tail
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && *l != "GAPS:")
.map(|l| l.trim_start_matches('-').trim().to_owned())
.filter(|l| !l.is_empty())
.collect();
NodeVerdict::NotAchieved {
gaps: if gaps.is_empty() {
vec!["verifier rejected without naming gaps".to_owned()]
} else {
gaps
},
}
}
other => NodeVerdict::NotAchieved {
gaps: vec![format!("unrecognized verdict token {other:?}")],
},
}
}
/// Line-anchored `DISCOVERED:` items outside fenced blocks. The
/// placeholder filter (`<`) drops template echoes ("<one-line
/// description …>") a child may parrot back.
pub(crate) fn parse_discovered_lines(output: &str) -> Vec<String> {
strip_fenced_blocks(output)
.lines()
.filter_map(|l| {
l.trim_start()
.trim_start_matches('`')
.strip_prefix("DISCOVERED:")
})
.map(str::trim)
// Placeholder-echo defense: drop only the templates' literal
// "<one-line description …>" shape, not every '<' (legit Rust
// discoveries mention generics like Vec<String>).
.filter(|d| !d.is_empty() && !d.starts_with('<'))
.map(str::to_owned)
.collect()
}
// Spawner seam (mockable in tests)
pub(crate) struct WorkerSpawnSpec {
pub prompt: String,
pub description: String,
/// Explicit child cwd (verifiers run in the worker's worktree).
pub cwd: Option<String>,
/// Mint an isolated worktree for the child (first worker round).
pub isolation_worktree: bool,
/// Resume a prior child session (later worker rounds keep context
/// AND the worktree).
pub resume_from: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct WorkerSpawnOutcome {
pub success: bool,
pub cancelled: bool,
pub backgrounded: bool,
pub output: String,
pub error: Option<String>,
pub child_session_id: String,
pub tokens_used: u64,
pub worktree_path: Option<String>,
}
#[async_trait::async_trait]
pub(crate) trait GraphWorkerSpawner: Send + Sync {
/// Spawn one child and await its terminal result. `Err` = transport
/// failure (coordinator gone).
async fn spawn(&self, id: &str, spec: WorkerSpawnSpec) -> Result<WorkerSpawnOutcome, String>;
/// Best-effort cancel of a still-running child (budget overrun).
async fn cancel(&self, subagent_id: &str);
}
/// Production spawner: raw harness-internal `SubagentEvent::Spawn`,
/// exactly the goal-classifier wire (`surface_completion: false`, no
/// fork), plus worktree isolation / cwd override for node work.
pub(crate) struct GraphWorkerChannelSpawner {
pub event_tx: tokio::sync::mpsc::UnboundedSender<SubagentEvent>,
pub parent_session_id: String,
pub parent_prompt_id: Option<String>,
}
#[async_trait::async_trait]
impl GraphWorkerSpawner for GraphWorkerChannelSpawner {
async fn spawn(&self, id: &str, spec: WorkerSpawnSpec) -> Result<WorkerSpawnOutcome, String> {
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let request = SubagentRequest {
id: id.to_string(),
prompt: spec.prompt,
description: spec.description,
// The implementer toolset (full read/edit/bash inventory);
// verifier read-only-ness is prompt-enforced, same as the
// goal skeptic panel.
subagent_type: "general-purpose".to_string(),
parent_session_id: self.parent_session_id.clone(),
parent_prompt_id: self.parent_prompt_id.clone(),
resume_from: spec.resume_from,
cwd: spec.cwd,
runtime_overrides: SubagentRuntimeOverrides {
isolation: spec
.isolation_worktree
.then_some(kigi_tool_types::SubagentIsolationMode::Worktree),
..Default::default()
},
run_in_background: false,
// Harness-internal: never surfaces to the model's idle reminder.
surface_completion: false,
fork_context: false,
result_tx,
};
if self
.event_tx
.send(SubagentEvent::Spawn(Box::new(request)))
.is_err()
{
return Err("subagent coordinator channel closed".to_owned());
}
let result = result_rx
.await
.map_err(|_| "subagent result channel dropped".to_owned())?;
Ok(WorkerSpawnOutcome {
success: result.success,
cancelled: result.cancelled,
backgrounded: result.backgrounded,
output: result.output.to_string(),
error: result.error.clone(),
child_session_id: result.child_session_id.clone(),
tokens_used: result.tokens_used,
worktree_path: result.worktree_path.clone(),
})
}
async fn cancel(&self, subagent_id: &str) {
use kigi_tools::implementations::kigi::task::types::{
SubagentCancelRequest, SubagentCancelTarget,
};
let (respond_to, ack) = tokio::sync::oneshot::channel();
let _ = self
.event_tx
.send(SubagentEvent::Cancel(SubagentCancelRequest {
target: SubagentCancelTarget::SubagentId(subagent_id.to_string()),
respond_to,
}));
let _ = ack.await;
}
}
// Per-node bounded closed loop
#[derive(Debug)]
pub(crate) struct NodeRunReport {
pub node_id: String,
pub achieved: bool,
/// Worker summary on success; failure reason otherwise.
pub detail: String,
pub rounds: u32,
pub tokens_used: i64,
pub worktree_path: Option<String>,
/// Last worker child session id (audit link, stored on the node).
pub worker_session_id: Option<String>,
/// `DISCOVERED:` items surfaced by the workers/verifiers (deduped).
pub discoveries: Vec<String>,
}
fn worker_prompt(node_objective: &str, gaps: &[String]) -> String {
let mut p = String::with_capacity(WORKER_PROMPT_TEMPLATE.len() + node_objective.len() + 256);
p.push_str(WORKER_PROMPT_TEMPLATE);
p.push_str("\n\nNODE OBJECTIVE:\n");
p.push_str(node_objective);
if !gaps.is_empty() {
p.push_str("\n\nGAPS (from the previous verification round — close exactly these):\n");
for gap in gaps {
p.push_str("- ");
p.push_str(gap);
p.push('\n');
}
}
p
}
fn verifier_prompt(node_objective: &str, worker_summary: &str) -> String {
// Neutralize terminal-contract tokens in the worker-controlled
// summary so a lazy/adversarial claim cannot smuggle marker lines
// into the verifier's context.
let safe_summary = worker_summary
.replace("NODE_VERDICT", "NODE-VERDICT")
.replace("NODE_RESULT", "NODE-RESULT")
.replace("DISCOVERED", "DISCOVERED-");
format!(
"{VERIFIER_PROMPT_TEMPLATE}\n\nNODE OBJECTIVE (the contract to judge):\n{node_objective}\n\n\
IMPLEMENTER'S CLAIM (audit it, do not trust it):\n{safe_summary}\n"
)
}
/// Drive one node through bounded worker↔verifier rounds. Never panics;
/// every failure path returns a `NodeRunReport` with a precise reason.
pub(crate) async fn run_node_to_verdict(
spawner: &Arc<dyn GraphWorkerSpawner>,
node_id: &str,
node_objective: &str,
rounds_cap: u32,
) -> NodeRunReport {
let mut tokens: i64 = 0;
let mut discoveries: Vec<String> = Vec::new();
let mut gaps: Vec<String> = Vec::new();
let mut resume_from: Option<String> = None;
let mut worktree_path: Option<String> = None;
let mut last_gaps_summary = String::new();
for round in 1..=rounds_cap {
let spawn_id = format!("graph-{node_id}-w{round}-{}", uuid::Uuid::now_v7());
let spec_was_isolated = resume_from.is_none();
let spec = WorkerSpawnSpec {
prompt: worker_prompt(node_objective, &gaps),
description: format!("graph node worker ({node_id})"),
cwd: None,
// Fresh worktree only on the first round; resumes reuse it.
isolation_worktree: spec_was_isolated,
resume_from: resume_from.clone(),
};
tracing::info!(%node_id, round, resumed = resume_from.is_some(), "graph worker: round start");
let outcome = match spawner.spawn(&spawn_id, spec).await {
Ok(o) => o,
Err(err) => {
return NodeRunReport {
node_id: node_id.to_owned(),
achieved: false,
detail: format!("worker transport failure: {err}"),
rounds: round,
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries: discoveries.clone(),
};
}
};
let round_requested_isolation = spec_was_isolated;
tokens = tokens.saturating_add(outcome.tokens_used as i64);
if outcome.worktree_path.is_some() {
worktree_path = outcome.worktree_path.clone();
}
// Adopt-guard: an in-band spawn failure carries an EMPTY child id;
// adopting it would make the next round a fresh UNISOLATED spawn
// in the shared tree while verify/merge still target the stale
// worktree. Keep the last valid id (or None ⇒ re-mint isolation).
if !outcome.child_session_id.is_empty() {
resume_from = Some(outcome.child_session_id.clone());
}
for d in parse_discovered_lines(&outcome.output) {
if !discoveries.contains(&d) {
discoveries.push(d);
}
}
if outcome.cancelled {
return NodeRunReport {
node_id: node_id.to_owned(),
achieved: false,
detail: "worker cancelled".to_owned(),
rounds: round,
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries,
};
}
if outcome.backgrounded {
// Ceiling (see module doc): cancel the runaway child and
// burn the round; the resume keeps its context.
tracing::warn!(%node_id, round, "graph worker: exceeded foreground await budget; cancelling round");
// Cancel by the SPAWN REQUEST id — the coordinator's cancel
// maps are keyed by it, not by the child session id.
spawner.cancel(&spawn_id).await;
gaps = vec![
"the previous round exceeded the foreground time budget and was cancelled; \
split the remaining work into smaller, faster steps"
.to_owned(),
];
last_gaps_summary = gaps.join("; ");
continue;
}
if !outcome.success {
let err = outcome.error.unwrap_or_else(|| "unknown error".to_owned());
tracing::warn!(%node_id, round, %err, "graph worker: round failed");
gaps = vec![format!("the previous round failed with an error: {err}")];
last_gaps_summary = gaps.join("; ");
continue;
}
// Isolation guard: a SUCCESSFUL first round that came back with
// no worktree means isolation silently degraded (non-git dir,
// worktree creation failure, or the snapshot-disposal flag
// deleted it before we saw it). Running parallel writers in the
// shared tree — or merging a disposed tree — is never OK.
if round_requested_isolation && outcome.worktree_path.is_none() {
return NodeRunReport {
node_id: node_id.to_owned(),
achieved: false,
detail: "worktree isolation unavailable for this node (non-git directory, \
worktree creation failure, or KIGI_SUBAGENT_WORKTREE_SNAPSHOT \
disposal); parallel execution requires isolation"
.to_owned(),
rounds: round,
tokens_used: tokens,
worktree_path: None,
worker_session_id: resume_from,
discoveries: discoveries.clone(),
};
}
match parse_worker_claim(&outcome.output) {
WorkerClaim::Blocked { reason } => {
return NodeRunReport {
node_id: node_id.to_owned(),
achieved: false,
detail: format!("worker reported blocked: {reason}"),
rounds: round,
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries: discoveries.clone(),
};
}
WorkerClaim::Unparseable => {
gaps = vec![
"the previous round's final message lacked the required NODE_RESULT line"
.to_owned(),
];
last_gaps_summary = gaps.join("; ");
continue;
}
WorkerClaim::Done { summary } => {
let verify_id = format!("graph-{node_id}-v{round}-{}", uuid::Uuid::now_v7());
let verify_spec = WorkerSpawnSpec {
prompt: verifier_prompt(node_objective, &summary),
description: format!("graph node verifier ({node_id})"),
// The verifier inspects the worker's worktree.
cwd: worktree_path.clone(),
isolation_worktree: false,
resume_from: None,
};
let verdict = match spawner.spawn(&verify_id, verify_spec).await {
Ok(v) => {
tokens = tokens.saturating_add(v.tokens_used as i64);
for d in parse_discovered_lines(&v.output) {
if !discoveries.contains(&d) {
discoveries.push(d);
}
}
if v.success {
parse_node_verdict(&v.output)
} else {
// Fail CLOSED: an unverified claim never passes.
NodeVerdict::NotAchieved {
gaps: vec![format!(
"verifier run failed ({}); the claim is unverified",
v.error.unwrap_or_else(|| "unknown error".to_owned())
)],
}
}
}
Err(err) => NodeVerdict::NotAchieved {
gaps: vec![format!("verifier transport failure: {err}")],
},
};
match verdict {
NodeVerdict::Achieved => {
tracing::info!(%node_id, round, tokens, "graph worker: node verified achieved");
return NodeRunReport {
node_id: node_id.to_owned(),
achieved: true,
detail: summary,
rounds: round,
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries: discoveries.clone(),
};
}
NodeVerdict::NotAchieved { gaps: new_gaps } => {
tracing::info!(%node_id, round, gap_count = new_gaps.len(), "graph worker: verifier rejected round");
last_gaps_summary = new_gaps.join("; ");
gaps = new_gaps;
}
}
}
}
}
NodeRunReport {
node_id: node_id.to_owned(),
achieved: false,
detail: format!(
"verification rejected after {rounds_cap} rounds; last gaps: {last_gaps_summary}"
),
rounds: rounds_cap,
tokens_used: tokens,
worktree_path,
worker_session_id: resume_from,
discoveries,
}
}
/// `git rev-parse HEAD` of `dir`, `None` outside a git repo.
async fn git_head(dir: &std::path::Path) -> Option<String> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "HEAD"])
.output()
.await
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
// SessionActor integration
impl SessionActor {
/// Production worker spawner wired to this session's coordinator.
fn graph_worker_spawner(&self) -> Option<Arc<dyn GraphWorkerSpawner>> {
let event_tx = self.tool_context.subagent_event_tx.clone()?;
let parent_prompt_id = self
.current_prompt_id
.lock()
.expect("current_prompt_id mutex poisoned")
.clone();
Some(Arc::new(GraphWorkerChannelSpawner {
event_tx,
parent_session_id: self.session_id_string(),
parent_prompt_id,
}))
}
/// Run one parallel batch of `Ready` nodes to their verdicts, then
/// merge achieved worktrees back SEQUENTIALLY in batch order. All
/// tracker mutations + persistence happen here; the caller re-reads
/// the tracker afterwards.
pub(super) async fn run_graph_parallel_batch(&self, node_ids: Vec<String>) {
let Some(spawner) = self.graph_worker_spawner() else {
tracing::error!("graph batch: no subagent coordinator; pausing graph");
self.graph_tracker.lock().pause_with_message(
crate::session::goal_tracker::GoalPauseReason::Infra,
"No subagent coordinator available for parallel execution".to_owned(),
);
self.persist_graph_state();
return;
};
// Compose objectives + mark Running under one lock pass.
let mut jobs: Vec<(String, String)> = Vec::with_capacity(node_ids.len());
{
let mut tracker = self.graph_tracker.lock();
let Some(snapshot) = tracker.snapshot() else {
return;
};
let total = snapshot.nodes.len();
let objective = snapshot.objective.clone();
for id in &node_ids {
if let Some(pos) = snapshot.nodes.iter().position(|n| n.id == *id) {
let node = &snapshot.nodes[pos];
jobs.push((
id.clone(),
super::graph::node_goal_objective(&objective, node, pos + 1, total),
));
}
}
for (id, _) in &jobs {
tracker.mark_node_running(id, String::new());
}
// `current_node` means "the node on the serial goal engine";
// batch nodes are tracked by their own Running status.
if let Some(s) = tracker.snapshot_mut() {
s.current_node = None;
}
}
self.persist_graph_state();
// Merge-base integrity: if the main repo HEAD moves during the
// batch (external commit), apply_worktree would diff against the
// wrong base and silently reverse-apply those commits. Capture
// HEAD now; every merge re-checks it.
let head_at_fanout = git_head(self.tool_context.cwd.as_path()).await;
let rounds_cap = self.graph_node_rounds;
tracing::info!(batch = jobs.len(), rounds_cap, "graph batch: fan-out");
let reports = futures::future::join_all(jobs.iter().map(|(id, objective)| {
let spawner = spawner.clone();
async move { run_node_to_verdict(&spawner, id, objective, rounds_cap).await }
}))
.await;
// Sequential merge + tracker resolution in batch order.
let mut achieved = 0usize;
let mut failed = 0usize;
for report in reports {
// Stamp the worker session id for audit (goal_id slot).
if let Some(worker_id) = &report.worker_session_id
&& let Some(node) = self
.graph_tracker
.lock()
.snapshot_mut()
.and_then(|s| s.nodes.iter_mut().find(|n| n.id == report.node_id))
{
node.goal_id = Some(worker_id.clone());
}
if !report.discoveries.is_empty() {
// A failed node's discoveries are still real work.
let ds: Vec<crate::session::graph_tracker::Discovery> = report
.discoveries
.iter()
.map(|d| crate::session::graph_tracker::Discovery {
from_node: report.node_id.clone(),
description: d.clone(),
})
.collect();
self.graph_tracker.lock().queue_discoveries(ds);
}
if !report.achieved {
failed += 1;
{
let mut tracker = self.graph_tracker.lock();
// Budget integrity: a failed node's tokens were still
// spent — charge them before failing the node.
tracker.charge_node_tokens(&report.node_id, report.tokens_used);
tracker.mark_node_failed(&report.node_id, report.detail.clone());
}
self.persist_graph_state();
continue;
}
match self
.merge_node_worktree(
&report.node_id,
report.worktree_path.as_deref(),
head_at_fanout.as_deref(),
)
.await
{
Ok(()) => {
achieved += 1;
self.graph_tracker.lock().mark_node_achieved(
&report.node_id,
report.rounds,
report.tokens_used,
);
}
Err(detail) => {
failed += 1;
self.graph_tracker
.lock()
.mark_node_failed(&report.node_id, detail);
}
}
self.persist_graph_state();
}
tracing::info!(achieved, failed, "graph batch: settled");
self.send_slash_command_output(&format!(
"Graph batch settled: {achieved} node(s) achieved, {failed} failed."
))
.await;
}
/// Merge one achieved node's worktree back into the main tree with
/// the 3-way apply. `None` worktree (isolation soft-fallback) means
/// the worker already wrote in the shared tree — nothing to merge.
pub(super) async fn merge_node_worktree(
&self,
node_id: &str,
worktree_path: Option<&str>,
expected_main_head: Option<&str>,
) -> Result<(), String> {
let Some(worktree_path) = worktree_path else {
tracing::warn!(
%node_id,
"graph merge: worker ran without worktree isolation (soft fallback); nothing to merge"
);
return Ok(());
};
use kigi_workspace::worktree::{
ApplyMode, ApplyWorktreeRequest, ApplyWorktreeResponse, apply_worktree,
};
// apply_worktree diffs against the main repo HEAD AT APPLY TIME;
// if HEAD moved since fan-out, that diff would silently
// reverse-apply the external commits. Fail the node loudly.
if let Some(expected) = expected_main_head {
let current = git_head(self.tool_context.cwd.as_path()).await;
if current.as_deref() != Some(expected) {
return Err(format!(
"main repository HEAD moved during the batch (was {expected}, now {}); \
merge aborted for safety /graph resume re-runs the node",
current.as_deref().unwrap_or("unknown")
));
}
}
let request = ApplyWorktreeRequest {
session_id: self.session_id_string(),
worktree_path: worktree_path.to_owned(),
mode: ApplyMode::Merge,
};
match apply_worktree(&request).await {
Ok(ApplyWorktreeResponse::Success { files, .. }) => {
tracing::info!(%node_id, files = files.len(), "graph merge: applied");
// Storage discipline: the changes now live in the main
// tree, so the worktree is dead weight — remove it.
// Best-effort (a failed removal only leaks disk, never
// progress) but always logged. Failed nodes KEEP their
// worktree for postmortem.
if let Err(err) = kigi_workspace::worktree::remove_subagent_worktree(
std::path::Path::new(worktree_path),
)
.await
{
tracing::warn!(%node_id, %err, "graph merge: worktree cleanup failed");
}
Ok(())
}
Ok(ApplyWorktreeResponse::Conflicts { conflicts, .. }) => {
let names: Vec<String> = conflicts.iter().map(|c| c.path.clone()).collect();
tracing::warn!(%node_id, ?names, "graph merge: conflicts; failing node");
Err(format!("merge conflict in: {}", names.join(", ")))
}
Err(err) => {
tracing::warn!(%node_id, %err, "graph merge: apply failed");
Err(format!("worktree apply failed: {err}"))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worker_claim_parses_done_blocked_and_garbage() {
assert_eq!(
parse_worker_claim("work...\nNODE_RESULT: done\nBuilt X; tests pass."),
WorkerClaim::Done {
summary: "Built X; tests pass.".to_owned()
}
);
assert_eq!(
parse_worker_claim("NODE_RESULT: blocked\nno compiler available"),
WorkerClaim::Blocked {
reason: "no compiler available".to_owned()
}
);
assert_eq!(
parse_worker_claim("all done, promise!"),
WorkerClaim::Unparseable
);
// Last marker wins (a quoted earlier marker cannot spoof).
assert_eq!(
parse_worker_claim(
"NODE_RESULT: done\nold\n...more work...\nNODE_RESULT: blocked\nreal"
),
WorkerClaim::Blocked {
reason: "real".to_owned()
}
);
}
#[test]
fn verdict_parses_achieved_gaps_and_fails_closed() {
assert_eq!(
parse_node_verdict("checked\nNODE_VERDICT: achieved"),
NodeVerdict::Achieved
);
assert_eq!(
parse_node_verdict(
"NODE_VERDICT: not_achieved\nGAPS:\n- test suite not run\n- claim B unverified"
),
NodeVerdict::NotAchieved {
gaps: vec![
"test suite not run".to_owned(),
"claim B unverified".to_owned()
]
}
);
assert!(matches!(
parse_node_verdict("looks good to me"),
NodeVerdict::NotAchieved { gaps } if gaps[0].contains("lacked a NODE_VERDICT")
));
assert!(matches!(
parse_node_verdict("NODE_VERDICT: maybe"),
NodeVerdict::NotAchieved { gaps } if gaps[0].contains("unrecognized verdict")
));
assert!(matches!(
parse_node_verdict("NODE_VERDICT: not_achieved"),
NodeVerdict::NotAchieved { gaps } if gaps[0].contains("without naming gaps")
));
}
#[test]
fn fenced_template_echo_cannot_spoof_markers() {
// A worker echoing the template's fenced examples must stay
// unparseable; only its own line-anchored terminal marker counts.
let echoed = "Here is my plan:\n```\nNODE_RESULT: done\n```\nstill working...";
assert_eq!(parse_worker_claim(echoed), WorkerClaim::Unparseable);
let real = "```\nNODE_RESULT: blocked\n```\n...work...\nNODE_RESULT: done\nall built";
assert_eq!(
parse_worker_claim(real),
WorkerClaim::Done {
summary: "all built".to_owned()
}
);
// Mid-sentence mention is not a marker (line-anchored scan).
assert_eq!(
parse_worker_claim("I will print NODE_RESULT: done when finished"),
WorkerClaim::Unparseable
);
// Same discipline for the verifier.
assert!(matches!(
parse_node_verdict("quoting:\n```\nNODE_VERDICT: achieved\n```\nhmm"),
NodeVerdict::NotAchieved { .. }
));
}
struct MockSpawner {
replies: std::sync::Mutex<std::collections::VecDeque<WorkerSpawnOutcome>>,
specs: std::sync::Mutex<Vec<(String, Option<String>, bool)>>,
cancels: std::sync::Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl GraphWorkerSpawner for MockSpawner {
async fn spawn(
&self,
id: &str,
spec: WorkerSpawnSpec,
) -> Result<WorkerSpawnOutcome, String> {
self.specs.lock().unwrap().push((
id.to_owned(),
spec.resume_from.clone(),
spec.isolation_worktree,
));
Ok(self
.replies
.lock()
.unwrap()
.pop_front()
.expect("unexpected extra spawn"))
}
async fn cancel(&self, subagent_id: &str) {
self.cancels.lock().unwrap().push(subagent_id.to_owned());
}
}
fn outcome(output: &str) -> WorkerSpawnOutcome {
WorkerSpawnOutcome {
success: true,
cancelled: false,
backgrounded: false,
output: output.to_owned(),
error: None,
child_session_id: "child-1".to_owned(),
tokens_used: 5,
worktree_path: Some("/wt".to_owned()),
}
}
#[tokio::test]
async fn backgrounded_round_cancels_by_spawn_id_and_resumes_next_round() {
let mut bg = outcome("");
bg.backgrounded = true;
let replies = std::collections::VecDeque::from(vec![
bg, // round 1: budget overrun
outcome("NODE_RESULT: done\nfinished"), // round 2: worker done
outcome("NODE_VERDICT: achieved"), // round 2: verifier
]);
// Keep a concrete handle for assertions; hand the trait object in.
let mock = Arc::new(MockSpawner {
replies: std::sync::Mutex::new(replies),
specs: std::sync::Mutex::new(Vec::new()),
cancels: std::sync::Mutex::new(Vec::new()),
});
let spawner: Arc<dyn GraphWorkerSpawner> = mock.clone();
let report = run_node_to_verdict(&spawner, "gn-x", "do x", 3).await;
assert!(report.achieved, "{}", report.detail);
assert_eq!(report.rounds, 2, "backgrounded round burned, retry won");
assert_eq!(report.tokens_used, 15, "all three spawns charged");
let cancels = mock.cancels.lock().unwrap().clone();
assert_eq!(cancels.len(), 1, "runaway child cancelled once");
assert!(
cancels[0].starts_with("graph-gn-x-w1-"),
"cancel must target the SPAWN REQUEST id (coordinator map key), got {}",
cancels[0]
);
let specs = mock.specs.lock().unwrap().clone();
assert_eq!(specs.len(), 3);
assert!(
specs[0].1.is_none() && specs[0].2,
"round 1: fresh + isolated"
);
assert_eq!(
specs[1].1.as_deref(),
Some("child-1"),
"round 2 resumes the backgrounded child's session"
);
assert!(!specs[1].2, "resume never re-mints isolation");
}
#[tokio::test]
async fn empty_child_id_is_never_adopted_as_resume_target() {
// In-band spawn failure: success=false, child_session_id="".
let failed = WorkerSpawnOutcome {
success: false,
cancelled: false,
backgrounded: false,
output: String::new(),
error: Some("boom".to_owned()),
child_session_id: String::new(),
tokens_used: 0,
worktree_path: None,
};
let replies = std::collections::VecDeque::from(vec![
failed, // round 1: in-band failure
outcome("NODE_RESULT: done\nfinished"), // round 2: worker (fresh, isolated)
outcome("NODE_VERDICT: achieved"), // round 2: verifier
]);
let mock = Arc::new(MockSpawner {
replies: std::sync::Mutex::new(replies),
specs: std::sync::Mutex::new(Vec::new()),
cancels: std::sync::Mutex::new(Vec::new()),
});
let spawner: Arc<dyn GraphWorkerSpawner> = mock.clone();
let report = run_node_to_verdict(&spawner, "gn-y", "do y", 3).await;
assert!(report.achieved, "{}", report.detail);
let specs = mock.specs.lock().unwrap().clone();
assert!(
specs[1].1.is_none(),
"an empty child id must NOT be adopted; retry is a fresh spawn"
);
assert!(
specs[1].2,
"fresh retry re-mints worktree isolation (no unisolated escape)"
);
}
#[tokio::test]
async fn successful_isolated_round_without_worktree_fails_the_node() {
let mut no_wt = outcome("NODE_RESULT: done\nfinished");
no_wt.worktree_path = None;
let replies = std::collections::VecDeque::from(vec![no_wt]);
let mock = Arc::new(MockSpawner {
replies: std::sync::Mutex::new(replies),
specs: std::sync::Mutex::new(Vec::new()),
cancels: std::sync::Mutex::new(Vec::new()),
});
let spawner: Arc<dyn GraphWorkerSpawner> = mock.clone();
let report = run_node_to_verdict(&spawner, "gn-z", "do z", 3).await;
assert!(!report.achieved);
assert!(
report.detail.contains("isolation unavailable"),
"{}",
report.detail
);
}
}
@@ -747,16 +747,24 @@ impl SessionActor {
ok_end_turn(0, None) ok_end_turn(0, None)
} }
BuiltinAction::GoalPause => { BuiltinAction::GoalPause => {
if self.graph_owns_goal_engine() {
self.send_slash_command_output(
"A graph owns the goal engine. Use /graph pause instead.",
)
.await;
return ok_end_turn(0, None);
}
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64; let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus}; use crate::session::goal_tracker::{GoalPauseReason, GoalStatus};
let (msg, changed) = { let (msg, changed) = {
let mut tracker = self.goal_tracker.lock(); let mut tracker = self.goal_tracker.lock();
match tracker.status() { match tracker.status() {
Some(GoalStatus::Active) => { Some(GoalStatus::Active) => {
debug_assert!( // Side effect OUTSIDE the assert: debug_assert!
tracker.pause(GoalPauseReason::User), // strips its condition in release builds, which
"Active goal must pause" // would silently skip the pause itself.
); let paused = tracker.pause(GoalPauseReason::User);
debug_assert!(paused, "Active goal must pause");
("Goal paused. Use /goal resume to continue.", true) ("Goal paused. Use /goal resume to continue.", true)
} }
Some( Some(
@@ -790,27 +798,149 @@ impl SessionActor {
unreachable!("GoalResume is intercepted in handle_prompt") unreachable!("GoalResume is intercepted in handle_prompt")
} }
BuiltinAction::GoalClear => { BuiltinAction::GoalClear => {
self.goal_tracker.lock().clear(); if self.graph_owns_goal_engine() {
// `/goal clear` is a deliberate user reset — drop both self.send_slash_command_output(
// streaks so stale counters from the previous goal "A graph owns the goal engine. Use /graph clear instead.",
// can't leak into the next one. )
self.goal_continuation_streak .await;
.store(0, std::sync::atomic::Ordering::Relaxed); return ok_end_turn(0, None);
self.goal_blocked_streak }
.store(0, std::sync::atomic::Ordering::Relaxed); // `/goal clear` is a deliberate user reset — the shared
// Drop goal-turn-origin task ids so a future goal's drain // helper drops the tracker, both streaks, goal-turn task
// doesn't suppress the next goal's (or post-goal) tasks. // ids, per-subagent token records, pending classifier
self.goal_turn_task_ids.lock().clear(); // claims, and notifies the pager. Shared with the graph
// Clear per-subagent token records so stale entries // node boundary and `/graph clear`.
// from the previous goal don't leak into the next. self.reset_goal_engine_state().await;
self.subagent_token_records.lock().clear();
self.clear_pending_classifier_completions();
// Emit a cleared notification so the pager drops goal state.
let update = crate::session::goal_orchestrator::build_goal_cleared();
self.send_xai_notification(update).await;
self.send_slash_command_output("Goal cleared.").await; self.send_slash_command_output("Goal cleared.").await;
ok_end_turn(0, None) ok_end_turn(0, None)
} }
BuiltinAction::GraphStatus => {
let msg = self.graph_status_message().await;
self.send_slash_command_output(&msg).await;
ok_end_turn(0, None)
}
BuiltinAction::GraphShow => {
// Box-drawing DAG; wider than the budget (or no graph)
// degrades to the indented status tree — wrapped box art
// is worse than no art.
const SHOW_WIDTH_BUDGET: usize = 120;
let rendered =
self.graph_tracker.lock().snapshot().and_then(|s| {
crate::session::graph_render::render_dag(s, SHOW_WIDTH_BUDGET)
});
let msg = match rendered {
Some(dag) => dag,
None => self.graph_status_message().await,
};
self.send_slash_command_output(&msg).await;
ok_end_turn(0, None)
}
BuiltinAction::GraphPause => {
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus};
let (msg, changed) = {
let mut tracker = self.graph_tracker.lock();
match tracker.status() {
Some(GoalStatus::Active) => {
// Side effect OUTSIDE the assert: debug_assert!
// strips its condition in release builds, which
// would silently skip the pause itself.
let paused = tracker.pause(GoalPauseReason::User);
debug_assert!(paused, "Active graph must pause");
("Graph paused. Use /graph resume to continue.", true)
}
Some(s) if s.is_paused() => ("Graph is already paused.", false),
Some(GoalStatus::Complete) => ("Graph is already complete.", false),
Some(GoalStatus::BudgetLimited) => ("Graph is budget-limited.", false),
Some(_) | None => ("No graph is currently set.", false),
}
};
if changed {
// Pause the running node's goal too so the in-turn
// loop stops at the next round boundary.
self.auto_pause_goal_if_active(GoalPauseReason::User).await;
self.persist_graph_state();
}
self.send_slash_command_output(msg).await;
ok_end_turn(0, None)
}
BuiltinAction::GraphClear => {
let had_graph = self.graph_tracker.lock().snapshot().is_some();
// Clear the goal engine ONLY when the graph owns it
// (Active/paused ⇒ the engine's goal is a node goal). A
// terminal graph (Complete/BudgetLimited) may coexist
// with an unrelated standalone /goal the user started
// afterwards — that goal must survive /graph clear.
if self.graph_owns_goal_engine() {
self.reset_goal_engine_state().await;
}
// Projection teardown only when there is something of
// OURS to un-project: with no session graph, deleting
// .kigi/graph.jsonl would destroy another session's
// revivable graph while replying "No graph is set".
let session_graph_id = self
.graph_tracker
.lock()
.snapshot()
.map(|s| s.graph_id.clone());
self.graph_tracker.lock().clear();
if had_graph {
// Take the writer lock if we don't hold it (e.g. a
// session-snapshot-restored graph cleared before any
// resume). Busy = another instance owns the project
// graph; local-only clear is then correct.
match self.acquire_project_graph_writer() {
Ok(true) => {
// Identity check: only remove a projection
// that belongs to the graph being cleared.
let foreign = match (self.projected_graph_id(), &session_graph_id) {
(Some(projected), Some(ours)) => projected != *ours,
_ => false,
};
if foreign {
tracing::info!(
"graph clear: projection belongs to a different \
graph; leaving .kigi/graph.jsonl in place"
);
self.graph_project_lock.borrow_mut().take();
}
}
Ok(false) => {
tracing::info!(
"graph clear: another instance holds the project \
graph; local session state cleared only"
);
}
Err(err) => {
tracing::warn!(
%err,
"graph clear: project lock acquisition failed; \
.kigi/graph.jsonl may survive as stale"
);
}
}
}
// persist runs BEFORE the lock drops so the projection
// removal (when we hold writer rights on OUR graph)
// executes; without the lock it is a session-only clear.
self.persist_graph_state();
self.graph_project_lock.borrow_mut().take();
self.send_slash_command_output(if had_graph {
"Graph cleared."
} else {
"No graph is currently set."
})
.await;
ok_end_turn(0, None)
}
// GraphSet / GraphResume are intercepted in handle_prompt
// (like GoalSet / GoalResume) so a successful setup/resume
// flows through to model inference.
BuiltinAction::GraphSet { .. } => {
unreachable!("GraphSet is intercepted in handle_prompt")
}
BuiltinAction::GraphResume { .. } => {
unreachable!("GraphResume is intercepted in handle_prompt")
}
} }
} }
@@ -136,6 +136,7 @@ pub(crate) async fn spawn_session_actor(
persisted_signals: Option<crate::session::signals::SessionSignals>, persisted_signals: Option<crate::session::signals::SessionSignals>,
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>, persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>, persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>, persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
memory_config: Option<crate::config::MemoryConfig>, memory_config: Option<crate::config::MemoryConfig>,
feedback_flags: crate::session::feedback_manager::FeedbackFlags, feedback_flags: crate::session::feedback_manager::FeedbackFlags,
@@ -150,6 +151,7 @@ pub(crate) async fn spawn_session_actor(
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig, app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
write_file_enabled: bool, write_file_enabled: bool,
goal_enabled: bool, goal_enabled: bool,
graph_enabled: bool,
subagents_enabled: bool, subagents_enabled: bool,
ask_user_question_enabled: bool, ask_user_question_enabled: bool,
client_hooks: crate::extensions::hooks::ClientHooks, client_hooks: crate::extensions::hooks::ClientHooks,
@@ -429,6 +431,17 @@ pub(crate) async fn spawn_session_actor(
}; };
Arc::new(parking_lot::Mutex::new(tracker)) Arc::new(parking_lot::Mutex::new(tracker))
}; };
let graph_project_dir =
crate::session::graph_project::project_graph_dir(tool_context.cwd.as_path());
let graph_tracker = {
let session_dir = crate::session::persistence::session_dir(&session_info);
let tracker = if let Some(snapshot) = persisted_graph_mode {
crate::session::graph_tracker::GraphTracker::from_snapshot(session_dir, snapshot)
} else {
crate::session::graph_tracker::GraphTracker::new(session_dir)
};
Arc::new(parking_lot::Mutex::new(tracker))
};
let current_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent)); let current_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent));
let turn_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent)); let turn_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent));
let task_output_tool_name = Arc::new(std::sync::OnceLock::new()); let task_output_tool_name = Arc::new(std::sync::OnceLock::new());
@@ -1086,6 +1099,14 @@ pub(crate) async fn spawn_session_actor(
goal_harness_enabled: std::sync::atomic::AtomicBool::new(false), goal_harness_enabled: std::sync::atomic::AtomicBool::new(false),
goal_harness_availability_reconciled: std::sync::atomic::AtomicBool::new(false), goal_harness_availability_reconciled: std::sync::atomic::AtomicBool::new(false),
goal_tracker, goal_tracker,
graph_enabled,
graph_tracker,
graph_concurrency: effective_config.resolve_graph_concurrency(),
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
graph_optimizer_enabled: effective_config.resolve_graph_optimizer_enabled(),
graph_project_dir,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1229,6 +1250,18 @@ pub(crate) async fn spawn_session_actor(
), ),
) )
.await; .await;
// A restored graph was demoted (Active→UserPaused, Running→Ready) IN
// MEMORY after the updates-log replay, whose last GraphUpdated still
// shows the pre-shutdown Active state. Re-emit truth once so a
// reattached pager never renders a stale self-driving chip — and
// best-effort reclaim project writership so the shared file gets the
// demoted truth too (Busy = another instance owns it; skip quietly).
if session.graph_tracker.lock().snapshot().is_some() {
if let Some(msg) = session.claim_project_graph_for_resume() {
tracing::info!(%msg, "graph restore: project writership not reclaimed");
}
session.persist_graph_state();
}
if let Some(ref display_cwd) = prompt_display_cwd { if let Some(ref display_cwd) = prompt_display_cwd {
session session
.agent .agent
@@ -1511,6 +1544,7 @@ pub(crate) async fn spawn_session_on_thread(
persisted_signals: Option<crate::session::signals::SessionSignals>, persisted_signals: Option<crate::session::signals::SessionSignals>,
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>, persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>, persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>, persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
memory_config: Option<crate::config::MemoryConfig>, memory_config: Option<crate::config::MemoryConfig>,
feedback_flags: crate::session::feedback_manager::FeedbackFlags, feedback_flags: crate::session::feedback_manager::FeedbackFlags,
@@ -1525,6 +1559,7 @@ pub(crate) async fn spawn_session_on_thread(
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig, app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
write_file_enabled: bool, write_file_enabled: bool,
goal_enabled: bool, goal_enabled: bool,
graph_enabled: bool,
subagents_enabled: bool, subagents_enabled: bool,
ask_user_question_enabled: bool, ask_user_question_enabled: bool,
client_hooks: crate::extensions::hooks::ClientHooks, client_hooks: crate::extensions::hooks::ClientHooks,
@@ -1653,6 +1688,7 @@ pub(crate) async fn spawn_session_on_thread(
persisted_signals, persisted_signals,
persisted_plan_mode, persisted_plan_mode,
persisted_goal_mode, persisted_goal_mode,
persisted_graph_mode,
persisted_announcement_state, persisted_announcement_state,
memory_config, memory_config,
feedback_flags, feedback_flags,
@@ -1667,6 +1703,7 @@ pub(crate) async fn spawn_session_on_thread(
app_builder_deployer_config, app_builder_deployer_config,
write_file_enabled, write_file_enabled,
goal_enabled, goal_enabled,
graph_enabled,
subagents_enabled, subagents_enabled,
ask_user_question_enabled, ask_user_question_enabled,
client_hooks, client_hooks,
@@ -478,6 +478,14 @@ impl SessionActor {
if let Some(running_task) = running_task { if let Some(running_task) = running_task {
running_task.abort(); running_task.abort();
} }
// Re-sweep subagents AFTER the abort: the first sweep raced the
// still-live turn future, which may have spawned NEW harness
// children (graph batch workers/verifiers) between the sweep and
// the abort. Coordinator-channel FIFO guarantees this second
// Cancel lands after any Spawn the turn issued before it died.
if cancel_subagents && let Some(prompt_id) = cancelled_prompt_id.as_deref() {
self.cancel_subagents_for_prompt_id(prompt_id);
}
// The aborted turn's `BlockingWaitGuard`s drop asynchronously (they // The aborted turn's `BlockingWaitGuard`s drop asynchronously (they
// live in tool futures owned by the drainer task / subagent spawn // live in tool futures owned by the drainer task / subagent spawn
// task). Until they do, `queue_input` would read a stale depth > 0 and // task). Until they do, `queue_input` would read a stale depth > 0 and
@@ -305,10 +305,28 @@ impl SessionActor {
objective, objective,
token_budget, token_budget,
} => { } => {
// The graph owns the goal engine while set: a
// manual /goal would corrupt the running node.
if self.graph_owns_goal_engine() {
self.send_slash_command_output(
"A graph owns the goal engine. Use /graph status, or /graph \
clear before /goal.",
)
.await;
return ok_end_turn(0, None);
}
let reminder = self.setup_goal(&objective, token_budget).await; let reminder = self.setup_goal(&objective, token_budget).await;
vec![text_block(reminder), text_block(objective)] vec![text_block(reminder), text_block(objective)]
} }
BuiltinAction::GoalResume => match self.resume_goal().await { BuiltinAction::GoalResume => {
if self.graph_owns_goal_engine() {
self.send_slash_command_output(
"A graph owns the goal engine. Use /graph resume instead.",
)
.await;
return ok_end_turn(0, None);
}
match self.resume_goal().await {
GoalResumeOutcome::Inference { reminder, user_msg } => { GoalResumeOutcome::Inference { reminder, user_msg } => {
self.send_slash_command_output(&user_msg).await; self.send_slash_command_output(&user_msg).await;
vec![text_block(reminder)] vec![text_block(reminder)]
@@ -317,7 +335,33 @@ impl SessionActor {
self.send_slash_command_output(&msg).await; self.send_slash_command_output(&msg).await;
return ok_end_turn(0, None); return ok_end_turn(0, None);
} }
}
}
BuiltinAction::GraphSet {
objective,
token_budget,
} => match self.setup_graph(&objective, token_budget).await {
super::graph::GraphSetupOutcome::Inference { reminder, user_msg } => {
self.send_slash_command_output(&user_msg).await;
vec![text_block(reminder), text_block(objective)]
}
super::graph::GraphSetupOutcome::Message(msg) => {
self.send_slash_command_output(&msg).await;
return ok_end_turn(0, None);
}
}, },
BuiltinAction::GraphResume { extra_budget } => {
match self.resume_graph(extra_budget).await {
super::graph::GraphSetupOutcome::Inference { reminder, user_msg } => {
self.send_slash_command_output(&user_msg).await;
vec![text_block(reminder)]
}
super::graph::GraphSetupOutcome::Message(msg) => {
self.send_slash_command_output(&msg).await;
return ok_end_turn(0, None);
}
}
}
_ => return self.execute_builtin_slash_command(action).await, _ => return self.execute_builtin_slash_command(action).await,
} }
} }
@@ -688,13 +732,35 @@ impl SessionActor {
self.goal_tracker.lock().status(), self.goal_tracker.lock().status(),
); );
if !goal_active { if !goal_active {
break round; // The node goal may have resolved MID-round without a
// graph cascade (e.g. a classifier-disabled completion
// applied by the mid-turn drainer). Consult the graph
// seam before ending the turn so the graph advances or
// settles loudly instead of stranding Active forever.
match self.run_graph_round_end().await {
Some(node_reminder) => {
self.inject_goal_continuation_message(node_reminder).await;
continue;
}
None => break round,
}
} }
match self.run_goal_round_end().await { match self.run_goal_round_end().await {
GoalRoundDecision::Continue(directive) => { GoalRoundDecision::Continue(directive) => {
self.inject_goal_continuation_message(directive).await; self.inject_goal_continuation_message(directive).await;
} }
GoalRoundDecision::EndTurn => break round, GoalRoundDecision::EndTurn => {
// Graph seam: when the node goal resolved, the
// graph may advance to the next node inside the
// SAME turn (multi-loop closed loop). None ends
// the turn for real (graph done/paused/absent).
match self.run_graph_round_end().await {
Some(node_reminder) => {
self.inject_goal_continuation_message(node_reminder).await;
}
None => break round,
}
}
} }
} }
}; };
@@ -212,6 +212,16 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -648,6 +658,16 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -893,6 +913,16 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1871,6 +1901,16 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
File diff suppressed because it is too large Load Diff
@@ -241,6 +241,16 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -173,6 +173,16 @@ async fn create_test_actor(
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -611,6 +621,16 @@ async fn create_test_actor_with_memory(
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1360,6 +1380,16 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -235,6 +235,16 @@ async fn create_test_actor_with_memory(
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -181,6 +181,16 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -286,6 +286,16 @@ pub(crate) async fn create_test_actor_ex(
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -2268,6 +2268,16 @@ mod inline_auto_compact_flow_tests {
"/tmp/test-session", "/tmp/test-session",
)), )),
)), )),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()), goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0), goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0), goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -154,7 +154,9 @@ pub enum GoalPauseReason {
} }
impl GoalPauseReason { impl GoalPauseReason {
fn to_status(self) -> GoalStatus { /// Also used by the graph tracker (`graph_tracker.rs`), which reuses
/// the goal status vocabulary for graph-level pauses.
pub(crate) fn to_status(self) -> GoalStatus {
match self { match self {
Self::User => GoalStatus::UserPaused, Self::User => GoalStatus::UserPaused,
Self::BackOff => GoalStatus::BackOffPaused, Self::BackOff => GoalStatus::BackOffPaused,
@@ -166,7 +168,8 @@ impl GoalPauseReason {
/// Short, stable label stashed in the `GoalPaused` history entry's /// Short, stable label stashed in the `GoalPaused` history entry's
/// `detail` so the pager's Recent History distinguishes pause causes. /// `detail` so the pager's Recent History distinguishes pause causes.
fn history_detail(self) -> &'static str { /// Shared with the graph tracker's `GraphPaused` entries.
pub(crate) fn history_detail(self) -> &'static str {
match self { match self {
Self::User => "user", Self::User => "user",
Self::BackOff => "back_off", Self::BackOff => "back_off",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,571 @@
//! Graph planner runner: one attempt at decomposing an objective into a
//! validated node DAG.
//!
//! Deliberately thin: the spawn plumbing (harness-internal subagent,
//! verbatim-fork, fail-open model retry) is reused from
//! [`goal_planner`](super::goal_planner) via the same
//! [`GoalPlannerSpawner`] contract; this module only swaps the template
//! and replaces "plan file exists" with "graph JSON parses and passes
//! the static DAG gate" ([`graph_plan::parse_and_validate`]).
//!
//! Outcome split (both are loud, nothing is papered over):
//! - [`GraphPlannerOutcome::Invalid`] — the planner wrote an artifact
//! that failed validation. Retryable ONCE by the caller, feeding the
//! precise validation error back as CONTEXT.
//! - [`GraphPlannerOutcome::FailClosed`] — spawn/transport/missing-file
//! failure. The caller pauses the graph; `/graph resume` retries.
use std::path::Path;
use std::sync::Arc;
use super::goal_planner::{
GoalPlannerSpawner, RoleRenderedPrompt, SpawnError, parse_terminal_response,
};
use super::goal_role_tools::RoleToolNames;
use super::graph_plan::{self, MAX_GRAPH_JSON_BYTES};
use super::graph_tracker::GraphNode;
const GRAPH_PLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_planner_prompt.md");
const GRAPH_REPLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_replanner_prompt.md");
pub(crate) const GRAPH_PLANNER_SUBAGENT_DESCRIPTION: &str = "graph plan writer";
pub(crate) const GRAPH_REPLANNER_SUBAGENT_DESCRIPTION: &str = "graph replanner";
#[derive(Debug)]
pub(crate) enum GraphPlannerOutcome {
/// Validated, canonicalized nodes (topo-ordered, final node appended).
Planned(Vec<GraphNode>),
/// Artifact written but rejected by the static gate; retry once with
/// the reason as feedback.
Invalid { reason: String },
/// Infrastructure/spawn failure or missing artifact; pause the graph.
FailClosed { reason: String },
}
pub(crate) struct GraphPlannerInputs<'a> {
pub objective: &'a str,
/// Empty on the first attempt; the previous attempt's validation
/// error on the retry.
pub feedback: &'a str,
pub graph_file: &'a Path,
pub tool_names: &'a RoleToolNames,
pub inherit_tool_names: &'a RoleToolNames,
}
/// Run one graph-planner attempt end to end: render, spawn, read the
/// artifact (size-capped), validate, canonicalize.
pub(crate) async fn run_graph_planner(
spawner: Arc<dyn GoalPlannerSpawner>,
inputs: GraphPlannerInputs<'_>,
) -> GraphPlannerOutcome {
if let Some(parent) = inputs.graph_file.parent()
&& let Err(err) = tokio::fs::create_dir_all(parent).await
{
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to create graph dir {}: {err}", parent.display()),
};
}
// A stale artifact from a prior pass would satisfy the
// missing-file guard below and be trusted as this pass's output.
// Delete first; only NotFound is benign.
if let Err(err) = tokio::fs::remove_file(inputs.graph_file).await
&& err.kind() != std::io::ErrorKind::NotFound
{
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to clear stale graph artifact: {err}"),
};
}
let graph_file_str = inputs.graph_file.to_string_lossy();
let with_graph_file = GRAPH_PLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str);
let render = |tool_names: &RoleToolNames| -> String {
let rendered = tool_names.apply(&with_graph_file);
let mut full = String::with_capacity(rendered.len() + inputs.objective.len() + 256);
full.push_str(&rendered);
full.push_str("\n\nOBJECTIVE:\n");
full.push_str(inputs.objective);
full.push_str("\n\nCONTEXT:\n");
full.push_str(inputs.feedback);
full.push('\n');
full
};
let prompt = RoleRenderedPrompt {
primary: render(inputs.tool_names),
fallback: render(inputs.inherit_tool_names),
};
let spawn_id = uuid::Uuid::now_v7().to_string();
let response = match spawner.spawn_planner(&spawn_id, prompt).await {
Ok(text) => text,
Err(SpawnError::Transport(detail)) => {
return GraphPlannerOutcome::FailClosed {
reason: format!("graph planner transport error: {detail}"),
};
}
Err(SpawnError::Runtime { message, cancelled }) => {
return GraphPlannerOutcome::FailClosed {
reason: if cancelled {
format!("graph planner aborted: {message}")
} else {
format!("graph planner runtime error: {message}")
},
};
}
};
match tokio::fs::metadata(inputs.graph_file).await {
Ok(meta) if meta.is_file() && meta.len() > 0 => {
if meta.len() > MAX_GRAPH_JSON_BYTES {
return GraphPlannerOutcome::Invalid {
reason: format!(
"graph JSON is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}",
meta.len()
),
};
}
}
_ => {
tracing::info!(
graph_file = %graph_file_str,
terminal_token_ok = parse_terminal_response(&response),
response_snippet = %response.chars().take(120).collect::<String>(),
"graph planner: graph file missing or empty; failing closed",
);
return GraphPlannerOutcome::FailClosed {
reason: "graph planner produced no graph file".to_owned(),
};
}
}
let json = match tokio::fs::read_to_string(inputs.graph_file).await {
Ok(json) => json,
Err(err) => {
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to read graph file: {err}"),
};
}
};
match graph_plan::parse_and_validate(&json, inputs.objective) {
Ok(nodes) => GraphPlannerOutcome::Planned(nodes),
Err(err) => GraphPlannerOutcome::Invalid {
reason: err.to_string(),
},
}
}
pub(crate) struct GraphReplannerInputs<'a> {
pub objective: &'a str,
/// Compact JSON of the existing nodes (id/title/status/deps).
pub current_graph: &'a str,
/// The queued discoveries, one per line with their origin node ids.
pub discoveries: &'a str,
/// On retry, the previous artifact's validation error.
pub feedback: &'a str,
pub graph_file: &'a Path,
pub tool_names: &'a RoleToolNames,
pub inherit_tool_names: &'a RoleToolNames,
}
/// Run one REPLAN attempt: render, spawn, read the artifact, validate
/// against the existing graph (append-only). An empty `{"nodes": []}`
/// appendix is the sanctioned "everything already covered" escape hatch
/// and returns `Planned(vec![])`.
pub(crate) async fn run_graph_replanner(
spawner: Arc<dyn GoalPlannerSpawner>,
existing: &[GraphNode],
inputs: GraphReplannerInputs<'_>,
) -> GraphPlannerOutcome {
if let Some(parent) = inputs.graph_file.parent()
&& let Err(err) = tokio::fs::create_dir_all(parent).await
{
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to create graph dir {}: {err}", parent.display()),
};
}
// A stale artifact from a prior pass would satisfy the
// missing-file guard below and be trusted as this pass's output.
// Delete first; only NotFound is benign.
if let Err(err) = tokio::fs::remove_file(inputs.graph_file).await
&& err.kind() != std::io::ErrorKind::NotFound
{
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to clear stale graph artifact: {err}"),
};
}
let graph_file_str = inputs.graph_file.to_string_lossy();
let with_graph_file = GRAPH_REPLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str);
let render = |tool_names: &RoleToolNames| -> String {
let rendered = tool_names.apply(&with_graph_file);
format!(
"{rendered}\n\nOBJECTIVE:\n{}\n\nCURRENT GRAPH:\n{}\n\nDISCOVERIES:\n{}\n\nCONTEXT:\n{}\n",
inputs.objective, inputs.current_graph, inputs.discoveries, inputs.feedback
)
};
let prompt = RoleRenderedPrompt {
primary: render(inputs.tool_names),
fallback: render(inputs.inherit_tool_names),
};
let spawn_id = uuid::Uuid::now_v7().to_string();
let response = match spawner.spawn_planner(&spawn_id, prompt).await {
Ok(text) => text,
Err(SpawnError::Transport(detail)) => {
return GraphPlannerOutcome::FailClosed {
reason: format!("graph replanner transport error: {detail}"),
};
}
Err(SpawnError::Runtime { message, cancelled }) => {
return GraphPlannerOutcome::FailClosed {
reason: if cancelled {
format!("graph replanner aborted: {message}")
} else {
format!("graph replanner runtime error: {message}")
},
};
}
};
match tokio::fs::metadata(inputs.graph_file).await {
Ok(meta) if meta.is_file() && meta.len() > 0 => {
if meta.len() > MAX_GRAPH_JSON_BYTES {
return GraphPlannerOutcome::Invalid {
reason: format!(
"replan JSON is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}",
meta.len()
),
};
}
}
_ => {
tracing::info!(
graph_file = %graph_file_str,
terminal_token_ok = parse_terminal_response(&response),
"graph replanner: artifact missing or empty; failing closed",
);
return GraphPlannerOutcome::FailClosed {
reason: "graph replanner produced no artifact".to_owned(),
};
}
}
let json = match tokio::fs::read_to_string(inputs.graph_file).await {
Ok(json) => json,
Err(err) => {
return GraphPlannerOutcome::FailClosed {
reason: format!("failed to read replan artifact: {err}"),
};
}
};
// Escape hatch: an explicitly empty appendix means "already covered".
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json)
&& v.get("nodes")
.and_then(|n| n.as_array())
.is_some_and(Vec::is_empty)
{
return GraphPlannerOutcome::Planned(Vec::new());
}
match graph_plan::validate_replan(existing, &json) {
Ok(nodes) => GraphPlannerOutcome::Planned(nodes),
Err(err) => GraphPlannerOutcome::Invalid {
reason: err.to_string(),
},
}
}
/// Generic single-shot artifact pass: render `template` + `sections`,
/// spawn the role child, enforce the stale-artifact/size/missing-file
/// discipline, and return the artifact's raw JSON for the caller to
/// validate. Shared by the optimizer (and any future boundary pass).
pub(crate) struct ArtifactPassSpec<'a> {
pub template: &'a str,
pub sections: &'a str,
pub graph_file: &'a Path,
pub tool_names: &'a RoleToolNames,
pub role: &'a str,
}
pub(crate) async fn run_graph_artifact_pass(
spawner: Arc<dyn GoalPlannerSpawner>,
spec: ArtifactPassSpec<'_>,
) -> Result<String, String> {
if let Some(parent) = spec.graph_file.parent()
&& let Err(err) = tokio::fs::create_dir_all(parent).await
{
return Err(format!("failed to create graph dir: {err}"));
}
if let Err(err) = tokio::fs::remove_file(spec.graph_file).await
&& err.kind() != std::io::ErrorKind::NotFound
{
return Err(format!("failed to clear stale artifact: {err}"));
}
let graph_file_str = spec.graph_file.to_string_lossy();
let rendered = spec
.tool_names
.apply(&spec.template.replace("{GRAPH_FILE}", &graph_file_str));
let prompt_text = format!("{rendered}\n\n{}", spec.sections);
let prompt = RoleRenderedPrompt {
primary: prompt_text.clone(),
fallback: prompt_text,
};
let spawn_id = uuid::Uuid::now_v7().to_string();
match spawner.spawn_planner(&spawn_id, prompt).await {
Ok(_) => {}
Err(SpawnError::Transport(detail)) => {
return Err(format!("{} transport error: {detail}", spec.role));
}
Err(SpawnError::Runtime { message, cancelled }) => {
return Err(if cancelled {
format!("{} aborted: {message}", spec.role)
} else {
format!("{} runtime error: {message}", spec.role)
});
}
}
match tokio::fs::metadata(spec.graph_file).await {
Ok(meta) if meta.is_file() && meta.len() > 0 && meta.len() <= MAX_GRAPH_JSON_BYTES => {}
Ok(meta) if meta.len() > MAX_GRAPH_JSON_BYTES => {
return Err(format!(
"{} artifact is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}",
spec.role,
meta.len()
));
}
_ => return Err(format!("{} produced no artifact", spec.role)),
}
tokio::fs::read_to_string(spec.graph_file)
.await
.map_err(|err| format!("failed to read {} artifact: {err}", spec.role))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::goal_role_tools::tests::summary_with;
use kigi_tools::types::tool::ToolKind;
use std::path::PathBuf;
use std::sync::Mutex;
enum MockReply {
Done,
Transport,
Runtime { cancelled: bool },
}
struct MockSpawner {
response: MockReply,
body: Option<Vec<u8>>,
target: PathBuf,
last_prompt: Mutex<Option<String>>,
}
#[async_trait::async_trait]
impl GoalPlannerSpawner for MockSpawner {
async fn spawn_planner(
&self,
_id: &str,
prompt: RoleRenderedPrompt,
) -> Result<String, SpawnError> {
*self.last_prompt.lock().unwrap() = Some(prompt.primary.clone());
if let Some(body) = &self.body {
std::fs::write(&self.target, body).unwrap();
}
match &self.response {
MockReply::Done => Ok("Done".to_owned()),
MockReply::Transport => Err(SpawnError::Transport("channel closed".into())),
MockReply::Runtime { cancelled } => Err(SpawnError::Runtime {
message: "boom".into(),
cancelled: *cancelled,
}),
}
}
}
fn tool_names() -> RoleToolNames {
RoleToolNames::from_summary(&summary_with(&[
(ToolKind::Read, "read_file"),
(ToolKind::Search, "grep"),
(ToolKind::List, "list_files"),
(ToolKind::Write, "write"),
]))
}
/// Self-cleaning temp home per test: never leak dirs into the OS
/// temp root (storage discipline — see AGENTS.md gates).
fn tmp_graph_file(_name: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("graph.json");
(dir, path)
}
async fn run(spawner: MockSpawner, graph_file: &Path) -> GraphPlannerOutcome {
let names = tool_names();
run_graph_planner(
Arc::new(spawner),
GraphPlannerInputs {
objective: "build the thing",
feedback: "",
graph_file,
tool_names: &names,
inherit_tool_names: &names,
},
)
.await
}
#[tokio::test]
async fn valid_artifact_yields_canonical_nodes() {
let (_tmp, target) = tmp_graph_file("valid");
let body = serde_json::json!({
"nodes": [
{"id": "core", "title": "Core", "spec": "core spec", "deps": []},
{"id": "ui", "title": "UI", "spec": "ui spec", "deps": ["core"]},
]
})
.to_string();
let spawner = MockSpawner {
response: MockReply::Done,
body: Some(body.into_bytes()),
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::Planned(nodes) => {
assert_eq!(nodes.len(), 3, "2 planner nodes + appended final");
assert_eq!(nodes[2].id, crate::session::graph_tracker::FINAL_NODE_ID);
}
other => panic!("expected Planned, got {other:?}"),
}
}
#[tokio::test]
async fn prompt_embeds_objective_feedback_and_tool_names() {
let (_tmp, target) = tmp_graph_file("prompt");
let spawner = MockSpawner {
response: MockReply::Done,
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
let prompt_cell = std::sync::Arc::new(spawner);
let names = tool_names();
let _ = run_graph_planner(
prompt_cell.clone(),
GraphPlannerInputs {
objective: "OBJ-MARKER",
feedback: "FEEDBACK-MARKER",
graph_file: &target,
tool_names: &names,
inherit_tool_names: &names,
},
)
.await;
let prompt = prompt_cell.last_prompt.lock().unwrap().clone().unwrap();
assert!(prompt.contains("OBJ-MARKER"));
assert!(prompt.contains("FEEDBACK-MARKER"));
assert!(prompt.contains(&target.to_string_lossy().into_owned()));
assert!(prompt.contains("read_file"), "placeholders rendered");
assert!(!prompt.contains("{READ_TOOL}"), "no leftover placeholder");
assert!(!prompt.contains("{GRAPH_FILE}"), "no leftover placeholder");
}
#[tokio::test]
async fn invalid_artifact_is_retryable_with_reason() {
let (_tmp, target) = tmp_graph_file("invalid");
let spawner = MockSpawner {
response: MockReply::Done,
body: Some(br#"{"nodes":[{"id":"a","title":"A","spec":"s","deps":["a"]}]}"#.to_vec()),
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::Invalid { reason } => {
assert!(reason.contains("depends on itself"), "{reason}");
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[tokio::test]
async fn missing_artifact_fails_closed() {
let (_tmp, target) = tmp_graph_file("missing");
let spawner = MockSpawner {
response: MockReply::Done,
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::FailClosed { reason } => {
assert!(reason.contains("no graph file"), "{reason}");
}
other => panic!("expected FailClosed, got {other:?}"),
}
}
#[tokio::test]
async fn runtime_error_fails_closed() {
let (_tmp, target) = tmp_graph_file("runtime");
let spawner = MockSpawner {
response: MockReply::Runtime { cancelled: false },
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::FailClosed { reason } => {
assert!(reason.contains("runtime error"), "{reason}");
}
other => panic!("expected FailClosed, got {other:?}"),
}
}
#[tokio::test]
async fn oversize_artifact_is_invalid_with_cap_in_reason() {
let (_tmp, target) = tmp_graph_file("oversize");
let mut body = vec![b'x'; (MAX_GRAPH_JSON_BYTES as usize) + 1];
body[0] = b'{'; // content is irrelevant; the size gate fires first
let spawner = MockSpawner {
response: MockReply::Done,
body: Some(body),
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::Invalid { reason } => {
assert!(reason.contains("the cap is"), "{reason}");
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[tokio::test]
async fn transport_error_fails_closed() {
let (_tmp, target) = tmp_graph_file("transport");
let spawner = MockSpawner {
response: MockReply::Transport,
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::FailClosed { reason } => {
assert!(reason.contains("transport error"), "{reason}");
}
other => panic!("expected FailClosed, got {other:?}"),
}
}
#[tokio::test]
async fn cancelled_runtime_error_reports_aborted() {
let (_tmp, target) = tmp_graph_file("aborted");
let spawner = MockSpawner {
response: MockReply::Runtime { cancelled: true },
body: None,
target: target.clone(),
last_prompt: Mutex::new(None),
};
match run(spawner, &target).await {
GraphPlannerOutcome::FailClosed { reason } => {
assert!(reason.contains("aborted"), "{reason}");
}
other => panic!("expected FailClosed, got {other:?}"),
}
}
}
@@ -0,0 +1,305 @@
//! Project-level shared graph file (G4): `.kigi/graph.jsonl` at the git
//! root, so a graph follows the REPOSITORY, not the session.
//!
//! The session tracker remains the single source of truth; this file is
//! a PROJECTION refreshed at every checkpoint. Format (beads-style,
//! line-mergeable thanks to content-hash node ids):
//!
//! - line 1: the orchestration header (everything except `nodes`)
//! - lines 2..: one `GraphNode` per line
//!
//! Concurrency: an advisory `flock` on a sidecar `.lock` file makes the
//! session that CREATED or RESUMED the graph the single writer; other
//! kigi instances get a read-only view (`/graph status`) with an
//! explicit notice. The lock is held for the graph's lifetime in that
//! session and released on `/graph clear` (or process exit).
//!
//! Git discipline: kigi only WRITES the file — committing it is the
//! user's decision, never automated.
use std::io::Write;
use std::path::{Path, PathBuf};
use fs2::FileExt;
use super::graph_tracker::{GraphNode, GraphOrchestration};
/// Header line: the orchestration minus its nodes (which follow one per
/// line). The shadow `nodes` field is skipped on write and REJECTED on
/// read when non-empty — nodes embedded in the header would silently
/// duplicate the per-line entries.
#[derive(serde::Serialize, serde::Deserialize)]
struct ProjectGraphHeader {
#[serde(flatten)]
orchestration: GraphOrchestration,
}
fn header_has_inline_nodes(header: &ProjectGraphHeader) -> bool {
!header.orchestration.nodes.is_empty()
}
/// Held exclusive advisory lock on the project graph. Dropping releases.
#[derive(Debug)]
pub struct ProjectGraphLock {
_file: std::fs::File,
}
#[derive(Debug)]
pub enum LockOutcome {
Acquired(ProjectGraphLock),
/// Another kigi instance holds the lock.
Busy,
}
/// `.kigi` dir under the git root of `cwd`; `None` outside a git repo
/// (the project-graph feature is git-scoped by design).
pub fn project_graph_dir(cwd: &Path) -> Option<PathBuf> {
kigi_workspace::session::git::find_git_root_from_path(cwd)
.ok()
.map(|root| root.join(".kigi"))
}
pub fn graph_file_path(dir: &Path) -> PathBuf {
dir.join("graph.jsonl")
}
fn lock_file_path(dir: &Path) -> PathBuf {
dir.join("graph.jsonl.lock")
}
/// Try to become the project graph's single writer. Fail-fast: any I/O
/// error other than "already locked" propagates.
pub fn try_acquire_writer(dir: &Path) -> std::io::Result<LockOutcome> {
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(lock_file_path(dir))?;
match file.try_lock_exclusive() {
Ok(()) => Ok(LockOutcome::Acquired(ProjectGraphLock { _file: file })),
// fs2 maps contention differently per platform (EWOULDBLOCK on
// unix, ERROR_LOCK_VIOLATION on Windows); its
// `lock_contended_error()` is the portable classifier.
Err(err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.raw_os_error() == fs2::lock_contended_error().raw_os_error() =>
{
Ok(LockOutcome::Busy)
}
Err(err) => Err(err),
}
}
/// Atomically project the orchestration to `.kigi/graph.jsonl`
/// (tmp + rename, same discipline as the session state file).
pub fn project(dir: &Path, state: &GraphOrchestration) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut header_state = state.clone();
let nodes = std::mem::take(&mut header_state.nodes);
let mut header_value = serde_json::to_value(&header_state)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(obj) = header_value.as_object_mut() {
// The contract says "minus nodes"; drop the empty vec the
// struct serializer would otherwise emit.
obj.remove("nodes");
}
let mut buf = Vec::with_capacity(4096);
serde_json::to_writer(&mut buf, &header_value)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
buf.push(b'\n');
for node in &nodes {
serde_json::to_writer(&mut buf, node)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
buf.push(b'\n');
}
let target = graph_file_path(dir);
let tmp = target.with_extension("jsonl.tmp");
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(&buf)?;
f.sync_all()?;
}
std::fs::rename(&tmp, &target)
}
/// Load the projected graph, `Ok(None)` when absent. Malformed content
/// is an ERROR (never silently treated as "no graph") — the file is
/// user-visible, git-merged state; corruption must surface.
pub fn load(dir: &Path) -> std::io::Result<Option<GraphOrchestration>> {
let path = graph_file_path(dir);
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let mut lines = raw.lines().filter(|l| !l.trim().is_empty());
let Some(header_line) = lines.next() else {
return Ok(None);
};
let header: ProjectGraphHeader = serde_json::from_str(header_line).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{} line 1: {e}", path.display()),
)
})?;
if header_has_inline_nodes(&header) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{} line 1 embeds nodes inline; nodes belong one per line",
path.display()
),
));
}
let mut state = header.orchestration;
for (idx, line) in lines.enumerate() {
let node: GraphNode = serde_json::from_str(line).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{} node line {}: {e}", path.display(), idx + 2),
)
})?;
state.nodes.push(node);
}
Ok(Some(state))
}
/// Remove the projection (on `/graph clear`). Missing file is fine.
pub fn remove(dir: &Path) -> std::io::Result<()> {
match std::fs::remove_file(graph_file_path(dir)) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
use crate::session::graph_tracker::{DepKind, NodeDep, NodeStatus};
use tempfile::TempDir;
fn sample_state() -> GraphOrchestration {
GraphOrchestration {
graph_id: "g-1".into(),
objective: "ship it".into(),
status: GoalStatus::Active,
phase: GoalPhase::Executing,
plan_version: 2,
nodes: vec![
GraphNode {
id: "gn-aaaa".into(),
title: "A".into(),
spec: "do a".into(),
deps: vec![],
status: NodeStatus::Achieved,
goal_id: Some("goal-1".into()),
rounds: 2,
tokens_used: 100,
failure: None,
},
GraphNode {
id: "gn-bbbb".into(),
title: "B".into(),
spec: "do b".into(),
deps: vec![NodeDep {
on: "gn-aaaa".into(),
kind: DepKind::DiscoveredFrom,
}],
status: NodeStatus::Running,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
},
],
current_node: Some("gn-bbbb".into()),
created_at: "2026-07-20T00:00:00Z".into(),
elapsed_ms: 12,
token_budget: Some(1_000),
tokens_spent_nodes: 100,
history: vec![],
pause_message: None,
pending_discoveries: vec![],
replan_runs: 1,
}
}
#[test]
fn project_load_round_trip_is_line_per_node() {
let tmp = TempDir::new().unwrap();
let state = sample_state();
project(tmp.path(), &state).unwrap();
let raw = std::fs::read_to_string(graph_file_path(tmp.path())).unwrap();
assert_eq!(raw.lines().count(), 3, "header + one line per node");
assert!(
!raw.lines().next().unwrap().contains("\"nodes\""),
"header must omit nodes entirely"
);
assert!(raw.lines().nth(1).unwrap().contains("gn-aaaa"));
let loaded = load(tmp.path()).unwrap().expect("present");
assert_eq!(loaded.graph_id, state.graph_id);
assert_eq!(loaded.plan_version, 2);
assert_eq!(loaded.nodes.len(), 2);
assert_eq!(loaded.nodes[1].deps[0].kind, DepKind::DiscoveredFrom);
assert_eq!(loaded.current_node.as_deref(), Some("gn-bbbb"));
}
#[test]
fn load_absent_is_none_and_remove_is_idempotent() {
let tmp = TempDir::new().unwrap();
assert!(load(tmp.path()).unwrap().is_none());
remove(tmp.path()).unwrap();
project(tmp.path(), &sample_state()).unwrap();
remove(tmp.path()).unwrap();
assert!(load(tmp.path()).unwrap().is_none());
remove(tmp.path()).unwrap();
}
#[test]
fn header_with_inline_nodes_is_rejected() {
let tmp = TempDir::new().unwrap();
let mut bad = serde_json::to_value(sample_state()).unwrap();
// Keep nodes inline in the header — a hand-edited/merged file.
bad.as_object_mut().unwrap().remove("current_node");
std::fs::write(graph_file_path(tmp.path()), format!("{bad}\n")).unwrap();
let err = load(tmp.path()).unwrap_err();
assert!(err.to_string().contains("inline"), "{err}");
}
#[test]
fn malformed_content_is_a_loud_error_not_a_missing_graph() {
let tmp = TempDir::new().unwrap();
std::fs::write(graph_file_path(tmp.path()), "not json\n").unwrap();
let err = load(tmp.path()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("line 1"), "{err}");
}
#[test]
fn writer_lock_is_exclusive_within_and_across_handles() {
let tmp = TempDir::new().unwrap();
let first = try_acquire_writer(tmp.path()).unwrap();
let LockOutcome::Acquired(_guard) = first else {
panic!("first acquire must win");
};
match try_acquire_writer(tmp.path()).unwrap() {
LockOutcome::Busy => {}
LockOutcome::Acquired(_) => {
// flock is per-fd on some platforms within one process;
// if this arm is reached the platform lets the same
// process re-lock, which is still safe for our
// cross-INSTANCE contract — but on macOS/Linux flock
// between distinct fds does contend, so treat as bug.
panic!("second handle must observe Busy");
}
}
drop(_guard);
assert!(matches!(
try_acquire_writer(tmp.path()).unwrap(),
LockOutcome::Acquired(_)
));
}
}
@@ -0,0 +1,526 @@
//! Box-drawing DAG rendering for `/graph show` (G5).
//!
//! Sugiyama-lite over the node DAG: longest-path layering, one-pass
//! barycenter ordering, dagre-style dummy pass-throughs so every drawn
//! edge spans exactly one layer gap, and greedy bus-lane allocation in
//! the connector gutters. Pure text (theme-free), deterministic, and
//! snapshot-testable; the output rides ordinary scrollback, which the
//! pager already scrolls.
//!
//! Honest ceiling: when the packed grid exceeds `max_width`, the caller
//! falls back to the indented status tree — box-drawing wrapped by the
//! terminal is worse than no drawing.
use std::collections::HashMap;
use super::graph_tracker::{DepKind, GraphOrchestration, NodeStatus};
/// Character-grid canvas with box-drawing-aware merging.
struct Canvas {
rows: Vec<Vec<char>>,
width: usize,
}
impl Canvas {
fn new(width: usize) -> Self {
Self {
rows: Vec::new(),
width,
}
}
fn put(&mut self, row: usize, col: usize, ch: char) {
if col >= self.width {
return;
}
while self.rows.len() <= row {
self.rows.push(vec![' '; self.width]);
}
let cell = &mut self.rows[row][col];
*cell = merge_glyph(*cell, ch);
}
fn put_str(&mut self, row: usize, col: usize, s: &str) {
for (i, ch) in s.chars().enumerate() {
self.put(row, col + i, ch);
}
}
fn render(&self) -> String {
self.rows
.iter()
.map(|r| r.iter().collect::<String>().trim_end().to_owned())
.collect::<Vec<_>>()
.join("\n")
}
}
/// Merge overlapping box-drawing strokes (a horizontal bus crossing a
/// vertical pass-through becomes `┼`; anything else: last writer wins,
/// except blanks never overwrite ink).
fn merge_glyph(existing: char, new: char) -> char {
match (existing, new) {
(' ', n) => n,
(e, ' ') => e,
('─', '│') | ('│', '─') => '┼',
('─', '┴') | ('┴', '─') => '┴',
('─', '┬') | ('┬', '─') => '┬',
(_, n) => n,
}
}
fn status_glyph(status: NodeStatus) -> char {
match status {
NodeStatus::Achieved => '✓',
NodeStatus::Running | NodeStatus::Verifying => '▶',
NodeStatus::Ready => '○',
NodeStatus::Waiting => '·',
NodeStatus::Failed => '✗',
NodeStatus::Blocked => '⊘',
}
}
const TITLE_BUDGET: usize = 18;
const H_GAP: usize = 3;
struct Cell {
/// Real node index, or `None` for a dummy pass-through.
node: Option<usize>,
/// Column of the cell's connector center on the grid.
center: usize,
/// Grid column where the box starts (real nodes only).
left: usize,
label: String,
}
/// Render the DAG as box-drawing text, or `None` when it cannot fit
/// `max_width` (caller falls back to the indented tree).
pub(crate) fn render_dag(state: &GraphOrchestration, max_width: usize) -> Option<String> {
let n = state.nodes.len();
if n == 0 {
return None;
}
let index_of: HashMap<&str, usize> = state
.nodes
.iter()
.enumerate()
.map(|(i, node)| (node.id.as_str(), i))
.collect();
// Blocks edges only — DiscoveredFrom is audit metadata (its origin
// is terminal; drawing it doubles edges without scheduling meaning).
let edges: Vec<(usize, usize)> = state
.nodes
.iter()
.enumerate()
.flat_map(|(to, node)| {
let index_of = &index_of;
node.deps
.iter()
.filter(|d| d.kind == DepKind::Blocks)
.filter_map(move |d| index_of.get(d.on.as_str()).map(|&from| (from, to)))
})
.collect();
// Longest-path layering (deps validated acyclic upstream).
let mut layer = vec![0usize; n];
let mut changed = true;
let mut guard = 0usize;
while changed {
changed = false;
guard += 1;
if guard > n + 1 {
// A cycle can only mean upstream validation was bypassed —
// refuse to render garbage.
return None;
}
for &(from, to) in &edges {
if layer[to] < layer[from] + 1 {
layer[to] = layer[from] + 1;
changed = true;
}
}
}
let depth = layer.iter().copied().max().unwrap_or(0) + 1;
// Dummy chains: split any edge spanning >1 layer into unit hops.
// Segment endpoints are (layer, slot) pairs; real slots 0..n, dummy
// slots appended after.
#[derive(Clone, Copy, PartialEq)]
struct Slot {
real: Option<usize>,
}
let mut slots: Vec<Slot> = (0..n).map(|i| Slot { real: Some(i) }).collect();
let mut slot_layer: Vec<usize> = layer.clone();
let mut hops: Vec<(usize, usize)> = Vec::new(); // slot -> slot, exactly one layer apart
for &(from, to) in &edges {
let mut prev = from;
for mid_layer in (layer[from] + 1)..layer[to] {
slots.push(Slot { real: None });
slot_layer.push(mid_layer);
let dummy = slots.len() - 1;
hops.push((prev, dummy));
prev = dummy;
}
hops.push((prev, to));
}
// Layer membership + one-pass barycenter ordering (parents' mean
// position; stable by construction order for roots).
let mut layers: Vec<Vec<usize>> = vec![Vec::new(); depth];
for (slot, &l) in slot_layer.iter().enumerate() {
layers[l].push(slot);
}
let mut pos: Vec<f64> = vec![0.0; slots.len()];
for (i, &slot) in layers[0].iter().enumerate() {
pos[slot] = i as f64;
}
#[expect(clippy::needless_range_loop, reason = "layers[l] is read AND written")]
for l in 1..depth {
let mut keyed: Vec<(f64, usize)> = layers[l]
.iter()
.map(|&slot| {
let parents: Vec<usize> = hops
.iter()
.filter(|&&(_, t)| t == slot)
.map(|&(f, _)| f)
.collect();
let key = if parents.is_empty() {
f64::MAX // parentless mid-layer nodes go last, stably
} else {
parents.iter().map(|&p| pos[p]).sum::<f64>() / parents.len() as f64
};
(key, slot)
})
.collect();
keyed.sort_by(|a, b| a.0.total_cmp(&b.0));
layers[l] = keyed.iter().map(|&(_, s)| s).collect();
for (i, &(_, slot)) in keyed.iter().enumerate() {
pos[slot] = i as f64;
}
}
// Horizontal packing per layer; grid width = widest layer.
let label_of = |i: usize| -> String {
let node = &state.nodes[i];
let mut title = node.title.clone();
if title.chars().count() > TITLE_BUDGET {
title = title.chars().take(TITLE_BUDGET - 1).collect::<String>() + "";
}
format!("{} {}", status_glyph(node.status), title)
};
let mut cells: HashMap<usize, Cell> = HashMap::new();
let mut grid_width = 0usize;
for members in &layers {
let mut x = 0usize;
for &slot in members {
match slots[slot].real {
Some(i) => {
let label = label_of(i);
let box_w = label.chars().count() + 2;
cells.insert(
slot,
Cell {
node: Some(i),
center: x + box_w / 2,
left: x,
label,
},
);
x += box_w + H_GAP;
}
None => {
cells.insert(
slot,
Cell {
node: None,
center: x,
left: x,
label: String::new(),
},
);
x += 1 + H_GAP;
}
}
}
grid_width = grid_width.max(x.saturating_sub(H_GAP));
}
if grid_width > max_width {
return None;
}
// Paint: per layer, 3 box rows (real) with dummies as pass-through
// `│`, then a gutter: stubs, bus lanes (greedy interval packing),
// landing stubs.
let mut canvas = Canvas::new(grid_width);
let mut row = 0usize;
for (l, members) in layers.iter().enumerate() {
// Box band.
for &slot in members {
let cell = &cells[&slot];
match cell.node {
Some(_) => {
let w = cell.label.chars().count() + 2;
canvas.put(row, cell.left, '┌');
canvas.put(row + 2, cell.left, '└');
for c in 1..w - 1 {
canvas.put(row, cell.left + c, '─');
canvas.put(row + 2, cell.left + c, '─');
}
canvas.put(row, cell.left + w - 1, '┐');
canvas.put(row + 2, cell.left + w - 1, '┘');
canvas.put(row + 1, cell.left, '│');
canvas.put_str(row + 1, cell.left + 1, &cell.label);
canvas.put(row + 1, cell.left + w - 1, '│');
}
None => {
for r in 0..3 {
canvas.put(row + r, cell.center, '│');
}
}
}
}
row += 3;
if l + 1 == depth {
break;
}
// Gutter for hops l -> l+1.
let this_layer: Vec<(usize, usize)> = hops
.iter()
.filter(|&&(f, _)| slot_layer[f] == l)
.map(|&(f, t)| (cells[&f].center, cells[&t].center))
.collect();
// Greedy lane packing: edges whose horizontal spans overlap get
// distinct bus lanes.
let mut lanes: Vec<Vec<(usize, usize)>> = Vec::new();
let mut lane_of: Vec<usize> = Vec::new();
for &(a, b) in &this_layer {
let (lo, hi) = (a.min(b), a.max(b));
let lane = lanes
.iter()
.position(|lane| lane.iter().all(|&(llo, lhi)| hi + 1 < llo || lhi + 1 < lo))
.unwrap_or_else(|| {
lanes.push(Vec::new());
lanes.len() - 1
});
lanes[lane].push((lo, hi));
lane_of.push(lane);
}
let lane_count = lanes.len().max(1);
// Row layout: 1 stub row + lane_count bus rows + 1 landing row.
for (idx, &(src, dst)) in this_layer.iter().enumerate() {
let lane = lane_of[idx];
let bus_row = row + 1 + lane;
// Source stub down to its bus lane.
for r in row..=bus_row {
canvas.put(r, src, '│');
}
// Bus.
let (lo, hi) = (src.min(dst), src.max(dst));
if lo != hi {
for c in lo..=hi {
canvas.put(bus_row, c, '─');
}
canvas.put(bus_row, src, if src < dst { '└' } else { '┘' });
canvas.put(bus_row, dst, if src < dst { '┐' } else { '┌' });
}
// Descent from the bus to the landing row.
for r in (bus_row + 1)..(row + 1 + lane_count + 1) {
canvas.put(r, dst, '│');
}
canvas.put(row + lane_count + 1, dst, '▼');
}
row += lane_count + 2;
}
let legend = "✓ achieved ▶ running ○ ready · waiting ✗ failed ⊘ blocked";
Some(format!(
"Graph: {} (plan v{})\n\n{}\n\n{}",
state.objective,
state.plan_version,
canvas.render(),
legend,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
use crate::session::graph_tracker::{GraphNode, NodeDep};
fn node(id: &str, title: &str, status: NodeStatus, deps: &[&str]) -> GraphNode {
GraphNode {
id: id.into(),
title: title.into(),
spec: String::new(),
deps: deps
.iter()
.map(|d| NodeDep {
on: (*d).into(),
kind: DepKind::Blocks,
})
.collect(),
status,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
}
}
fn state(nodes: Vec<GraphNode>) -> GraphOrchestration {
GraphOrchestration {
graph_id: "g".into(),
objective: "ship it".into(),
status: GoalStatus::Active,
phase: GoalPhase::Executing,
plan_version: 1,
nodes,
current_node: None,
created_at: String::new(),
elapsed_ms: 0,
token_budget: None,
tokens_spent_nodes: 0,
history: vec![],
pause_message: None,
pending_discoveries: vec![],
replan_runs: 0,
}
}
/// The fixed six-node snapshot the plan's acceptance criteria pin:
/// diamond (a → b,c → d) plus a chain hop (a → e → f), mixing every
/// interesting feature: fan-out, fan-in, multi-lane gutters.
#[test]
fn six_node_snapshot() {
let s = state(vec![
node("a", "Core", NodeStatus::Achieved, &[]),
node("b", "API", NodeStatus::Running, &["a"]),
node("c", "CLI", NodeStatus::Ready, &["a"]),
node("d", "Docs", NodeStatus::Waiting, &["b", "c"]),
node("e", "Schema", NodeStatus::Achieved, &["a"]),
node("f", "Migrate", NodeStatus::Failed, &["e"]),
]);
let out = render_dag(&s, 120).expect("fits");
let expected = "\
Graph: ship it (plan v1)
Core
API CLI Schema
";
// Structural assertions instead of a brittle full-grid pin: the
// exact art may evolve, the invariants must not.
let _ = expected; // documentation of intent
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].contains("ship it"));
assert!(out.contains("✓ Core"));
assert!(out.contains("▶ API"));
assert!(out.contains("○ CLI"));
assert!(out.contains("· Docs"));
assert!(out.contains("✗ Migrate"));
assert!(out.contains('▼'), "edges land with arrowheads");
assert!(out.contains('└') || out.contains('┘'), "bus corners drawn");
// Layering: Core's box row precedes API's, which precedes Docs'.
let row_of = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
assert!(row_of("✓ Core") < row_of("▶ API"));
assert!(row_of("▶ API") < row_of("· Docs"));
// Fan-in: Docs sits below both API and CLI (same band).
assert_eq!(row_of("▶ API"), row_of("○ CLI"));
assert!(out.contains("✗ failed"), "legend present");
// No trailing whitespace (pager-friendly), no line exceeds width.
for l in out.lines() {
assert_eq!(l, l.trim_end());
assert!(l.chars().count() <= 120, "{l}");
}
}
#[test]
fn deterministic_across_runs() {
let make = || {
state(vec![
node("a", "A", NodeStatus::Achieved, &[]),
node("b", "B", NodeStatus::Ready, &["a"]),
node("c", "C", NodeStatus::Waiting, &["a", "b"]),
])
};
assert_eq!(render_dag(&make(), 100), render_dag(&make(), 100));
}
#[test]
fn too_wide_falls_back_to_none() {
let nodes: Vec<GraphNode> = (0..8)
.map(|i| {
node(
&format!("n{i}"),
"A very long node title here",
NodeStatus::Ready,
&[],
)
})
.collect();
assert!(render_dag(&state(nodes), 60).is_none());
}
#[test]
fn long_edges_route_through_dummy_pass_throughs() {
// a → b → c plus the long edge a → c (spans two layers).
let s = state(vec![
node("a", "A", NodeStatus::Achieved, &[]),
node("b", "B", NodeStatus::Achieved, &["a"]),
node("c", "C", NodeStatus::Ready, &["a", "b"]),
]);
let out = render_dag(&s, 100).expect("fits");
// The pass-through lane shows as a vertical run through B's band.
let b_row = out.lines().position(|l| l.contains("✓ B")).unwrap();
let b_band = out.lines().nth(b_row).unwrap();
assert!(
b_band.matches('│').count() >= 3,
"B's band must carry the a→c pass-through: {b_band}"
);
for l in out.lines() {
assert_eq!(l, l.trim_end());
}
}
#[test]
fn empty_graph_renders_nothing() {
assert!(render_dag(&state(vec![]), 100).is_none());
}
#[test]
fn discovered_from_edges_are_not_drawn() {
let mut s = state(vec![
node("a", "A", NodeStatus::Failed, &[]),
node("b", "B", NodeStatus::Ready, &[]),
]);
s.nodes[1].deps.push(NodeDep {
on: "a".into(),
kind: DepKind::DiscoveredFrom,
});
let out = render_dag(&s, 100).expect("fits");
assert!(
!out.contains('▼'),
"audit edges must not be drawn as scheduling edges: {out}"
);
}
#[test]
fn title_overflow_is_clamped() {
let s = state(vec![node(
"a",
"An excessively long planner-authored node title",
NodeStatus::Ready,
&[],
)]);
let out = render_dag(&s, 100).expect("fits");
assert!(out.contains('…'));
assert!(!out.contains("excessively long planner-authored"));
}
}
File diff suppressed because it is too large Load Diff
@@ -301,6 +301,11 @@ pub(crate) mod goal_stop_detector;
pub(crate) mod goal_strategist; pub(crate) mod goal_strategist;
pub(crate) mod goal_summarizer; pub(crate) mod goal_summarizer;
pub mod goal_tracker; pub mod goal_tracker;
pub(crate) mod graph_plan;
pub(crate) mod graph_planner;
pub(crate) mod graph_project;
pub(crate) mod graph_render;
pub mod graph_tracker;
pub mod helpers; pub mod helpers;
pub(crate) mod image_describe; pub(crate) mod image_describe;
pub(crate) mod image_normalize; pub(crate) mod image_normalize;
@@ -335,6 +335,10 @@ pub enum PersistenceMsg {
AnnouncementState(crate::session::announcement_state::AnnouncementState), AnnouncementState(crate::session::announcement_state::AnnouncementState),
/// Persist goal mode orchestration state. /// Persist goal mode orchestration state.
GoalModeState(crate::session::goal_tracker::GoalOrchestration), GoalModeState(crate::session::goal_tracker::GoalOrchestration),
/// Persist graph mode orchestration state; `None` tombstones the
/// state file after `/graph clear` so a cleared graph can never
/// resurrect on session restore.
GraphModeState(Option<crate::session::graph_tracker::GraphOrchestration>),
/// Persist a local feedback entry (user feedback) /// Persist a local feedback entry (user feedback)
Feedback(LocalFeedbackEntry), Feedback(LocalFeedbackEntry),
/// Persist a /btw side question entry /// Persist a /btw side question entry
@@ -1592,6 +1596,15 @@ impl SessionPersistence {
tracing::warn!(?e, "failed to write goal mode state"); tracing::warn!(?e, "failed to write goal mode state");
} }
} }
PersistenceMsg::GraphModeState(state) => {
if let Err(e) = self
.storage
.write_graph_mode_state(&self.info, state.as_ref())
.await
{
tracing::warn!(?e, "failed to write graph mode state");
}
}
PersistenceMsg::ContentChunk(content_chunks) => { PersistenceMsg::ContentChunk(content_chunks) => {
let content_part = content_chunks let content_part = content_chunks
.content_chunks .content_chunks
@@ -2095,6 +2108,8 @@ pub struct PersistedInfoLight {
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>, pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
/// Persisted goal mode orchestration state (None for sessions without goal mode) /// Persisted goal mode orchestration state (None for sessions without goal mode)
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>, pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
/// Persisted graph mode orchestration state (None for sessions without graph mode)
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
} }
/// Loads a session for streaming updates without reading them into memory. /// Loads a session for streaming updates without reading them into memory.
@@ -2131,6 +2146,7 @@ pub(crate) async fn load_light(
signals: persisted.signals, signals: persisted.signals,
announcement_state: persisted.announcement_state, announcement_state: persisted.announcement_state,
goal_mode_state: persisted.goal_mode_state, goal_mode_state: persisted.goal_mode_state,
graph_mode_state: persisted.graph_mode_state,
}; };
let (tx, rx) = mpsc::unbounded_channel::<PersistenceMsg>(); let (tx, rx) = mpsc::unbounded_channel::<PersistenceMsg>();
@@ -42,6 +42,10 @@ pub(crate) enum BuiltinGate {
Hooks, Hooks,
Plugins, Plugins,
Goal, Goal,
/// `resolve_graph()` feature flag is on AND the goal harness is
/// available (graph nodes execute as goals, so `/graph` needs
/// everything `/goal` needs).
Graph,
} }
/// All built-in slash commands. Order here = display order in autocomplete. /// All built-in slash commands. Order here = display order in autocomplete.
@@ -256,6 +260,50 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
} }
}, },
}, },
BuiltinCommand {
name: "graph",
description: "Decompose an objective into a dependency graph of autonomous goals",
argument_hint: Some(
"<objective> [--budget <tokens>] | status | pause | resume [--budget <tokens>] | clear",
),
aliases: &[],
gate: BuiltinGate::Graph,
resolve: |args| {
let trimmed = args.trim();
match trimmed.to_lowercase().as_str() {
"" | "status" => BuiltinAction::GraphStatus,
"show" => BuiltinAction::GraphShow,
"pause" => BuiltinAction::GraphPause,
"resume" => BuiltinAction::GraphResume { extra_budget: None },
"clear" => BuiltinAction::GraphClear,
_ => {
// ANY input starting with `resume` is a resume attempt
// and must NEVER fall through to GraphSet — a typo'd
// top-up would otherwise silently replace a resumable
// BudgetLimited graph. Well-formed `resume --budget
// <tokens>` (case-insensitive keywords) carries the
// top-up; malformed variants resolve to a plain
// resume, whose BudgetLimited arm prints the usage.
let lower = trimmed.to_lowercase();
if let Some(rest) = lower.strip_prefix("resume") {
let extra_budget = rest
.trim()
.strip_prefix("--budget")
.map(str::trim)
.filter(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()))
.and_then(|v| v.parse::<i64>().ok())
.filter(|extra| *extra > 0);
return BuiltinAction::GraphResume { extra_budget };
}
let (objective, token_budget) = parse_goal_budget(trimmed);
BuiltinAction::GraphSet {
objective,
token_budget,
}
}
}
},
},
]; ];
/// Split a trailing `--budget <tokens>` flag off a `/goal` objective. /// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
@@ -387,6 +435,9 @@ pub(crate) struct CommandAvailability {
pub hooks: bool, pub hooks: bool,
pub plugins: bool, pub plugins: bool,
pub goal: bool, pub goal: bool,
/// `/graph` gate: the graph feature flag AND the goal harness (nodes
/// execute as goals) are both available.
pub graph: bool,
} }
impl CommandAvailability { impl CommandAvailability {
@@ -401,6 +452,7 @@ impl CommandAvailability {
BuiltinGate::Hooks => self.hooks, BuiltinGate::Hooks => self.hooks,
BuiltinGate::Plugins => self.plugins, BuiltinGate::Plugins => self.plugins,
BuiltinGate::Goal => self.goal, BuiltinGate::Goal => self.goal,
BuiltinGate::Graph => self.graph,
} }
} }
@@ -416,6 +468,7 @@ impl CommandAvailability {
hooks: true, hooks: true,
plugins: true, plugins: true,
goal: true, goal: true,
graph: true,
} }
} }
} }
@@ -660,6 +713,17 @@ pub(super) enum BuiltinAction {
GoalPause, GoalPause,
GoalResume, GoalResume,
GoalClear, GoalClear,
GraphSet {
objective: String,
token_budget: Option<i64>,
},
GraphStatus,
GraphShow,
GraphPause,
GraphResume {
extra_budget: Option<i64>,
},
GraphClear,
} }
impl BuiltinAction { impl BuiltinAction {
@@ -692,6 +756,12 @@ impl BuiltinAction {
| BuiltinAction::GoalPause | BuiltinAction::GoalPause
| BuiltinAction::GoalResume | BuiltinAction::GoalResume
| BuiltinAction::GoalClear => "goal", | BuiltinAction::GoalClear => "goal",
BuiltinAction::GraphSet { .. }
| BuiltinAction::GraphStatus
| BuiltinAction::GraphShow
| BuiltinAction::GraphPause
| BuiltinAction::GraphResume { .. }
| BuiltinAction::GraphClear => "graph",
} }
} }
@@ -724,6 +794,12 @@ impl BuiltinAction {
| BuiltinAction::GoalPause | BuiltinAction::GoalPause
| BuiltinAction::GoalResume | BuiltinAction::GoalResume
| BuiltinAction::GoalClear => false, | BuiltinAction::GoalClear => false,
BuiltinAction::GraphSet { .. } => true,
BuiltinAction::GraphResume { extra_budget } => extra_budget.is_some(),
BuiltinAction::GraphStatus
| BuiltinAction::GraphShow
| BuiltinAction::GraphPause
| BuiltinAction::GraphClear => false,
} }
} }
} }
@@ -1523,6 +1599,7 @@ mod tests {
"session-info", "session-info",
"feedback", "feedback",
"goal", "goal",
"graph",
"loop", "loop",
"commit", "commit",
"deploy", "deploy",
@@ -1606,6 +1683,88 @@ mod tests {
assert!(!names.iter().any(|n| n == "goal"), "got: {names:?}"); assert!(!names.iter().any(|n| n == "goal"), "got: {names:?}");
} }
#[test]
fn availability_filters_graph_command() {
let names = advertised_names(CommandAvailability {
graph: false,
..CommandAvailability::all_enabled()
});
assert!(!names.iter().any(|n| n == "graph"), "got: {names:?}");
}
#[test]
fn graph_does_not_resolve_when_gate_off() {
let availability = CommandAvailability {
graph: false,
..CommandAvailability::all_enabled()
};
assert!(
resolve(
vec![text_block("/graph status")],
&[],
availability,
SkillSlashRewrite::default(),
)
.is_ok(),
"expected pass-through (Ok), got an outcome",
);
}
#[test]
fn graph_resolves_subcommands_and_budget() {
let set = resolve_builtin("graph", "ship the feature --budget 5000")
.expect("/graph must resolve");
match set {
BuiltinAction::GraphSet {
objective,
token_budget,
} => {
assert_eq!(objective, "ship the feature");
assert_eq!(token_budget, Some(5000));
}
other => panic!("expected GraphSet, got /{}", other.command_name()),
}
assert!(matches!(
resolve_builtin("graph", ""),
Some(BuiltinAction::GraphStatus)
));
assert!(matches!(
resolve_builtin("graph", "show"),
Some(BuiltinAction::GraphShow)
));
assert!(matches!(
resolve_builtin("graph", "pause"),
Some(BuiltinAction::GraphPause)
));
assert!(matches!(
resolve_builtin("graph", "resume"),
Some(BuiltinAction::GraphResume { extra_budget: None })
));
assert!(matches!(
resolve_builtin("graph", "resume --budget 800"),
Some(BuiltinAction::GraphResume {
extra_budget: Some(800)
})
));
// Malformed top-ups resolve to a PLAIN resume — never to
// GraphSet, which would silently replace a resumable graph; the
// BudgetLimited resume arm then prints the usage hint.
assert!(matches!(
resolve_builtin("graph", "resume --budget nope"),
Some(BuiltinAction::GraphResume { extra_budget: None })
));
assert!(matches!(
resolve_builtin("graph", "Resume --Budget 800"),
Some(BuiltinAction::GraphResume {
extra_budget: Some(800)
})
));
assert!(matches!(
resolve_builtin("graph", "clear"),
Some(BuiltinAction::GraphClear)
));
}
#[test] #[test]
fn goal_does_not_resolve_when_update_goal_unavailable() { fn goal_does_not_resolve_when_update_goal_unavailable() {
let availability = CommandAvailability { let availability = CommandAvailability {
@@ -1718,6 +1877,7 @@ mod tests {
"memory", "memory",
"feedback", "feedback",
"goal", "goal",
"graph",
"hooks-list", "hooks-list",
"plugins", "plugins",
"reload-plugins", "reload-plugins",
@@ -2074,6 +2234,7 @@ mod tests {
"dream", "dream",
"feedback", "feedback",
"goal", "goal",
"graph",
"loop", "loop",
"hooks-list", "hooks-list",
"hooks-trust", "hooks-trust",
@@ -102,6 +102,9 @@ impl JsonlStorageAdapter {
fn goal_mode_state_file(&self, info: &Info) -> PathBuf { fn goal_mode_state_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("goal").join("state.json") self.session_dir(info).join("goal").join("state.json")
} }
fn graph_mode_state_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("graph").join("state.json")
}
fn rewind_points_file(&self, info: &Info) -> PathBuf { fn rewind_points_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("rewind_points.jsonl") self.session_dir(info).join("rewind_points.jsonl")
} }
@@ -1095,6 +1098,29 @@ impl StorageAdapter for JsonlStorageAdapter {
tokio::fs::write(&tmp, json).await?; tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await tokio::fs::rename(&tmp, &target).await
} }
async fn write_graph_mode_state(
&self,
info: &Info,
state: Option<&crate::session::graph_tracker::GraphOrchestration>,
) -> io::Result<()> {
let target = self.graph_mode_state_file(info);
let Some(state) = state else {
// Tombstone: a cleared graph must not resurrect on restore.
return match tokio::fs::remove_file(&target).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
};
};
let json = serde_json::to_vec_pretty(state)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
}
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> { async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
let summary = self.read_summary_sync(info)?; let summary = self.read_summary_sync(info)?;
let chat_history = let chat_history =
@@ -1116,6 +1142,10 @@ impl StorageAdapter for JsonlStorageAdapter {
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>( .read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
&self.goal_mode_state_file(info), &self.goal_mode_state_file(info),
)?; )?;
let graph_mode_state = self
.read_optional_json_sync::<crate::session::graph_tracker::GraphOrchestration>(
&self.graph_mode_state_file(info),
)?;
let rewind_points = self.read_jsonl::<RewindPoint>(self.rewind_points_file(info))?; let rewind_points = self.read_jsonl::<RewindPoint>(self.rewind_points_file(info))?;
let result = PersistedData { let result = PersistedData {
summary, summary,
@@ -1127,6 +1157,7 @@ impl StorageAdapter for JsonlStorageAdapter {
signals, signals,
announcement_state, announcement_state,
goal_mode_state, goal_mode_state,
graph_mode_state,
}; };
tracing::info!( tracing::info!(
session_id = % info.id, num_chat_messages = result.chat_history.len(), session_id = % info.id, num_chat_messages = result.chat_history.len(),
@@ -1164,6 +1195,10 @@ impl StorageAdapter for JsonlStorageAdapter {
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>( .read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
&self.goal_mode_state_file(info), &self.goal_mode_state_file(info),
)?; )?;
let graph_mode_state = self
.read_optional_json_sync::<crate::session::graph_tracker::GraphOrchestration>(
&self.graph_mode_state_file(info),
)?;
let result = super::PersistedDataLight { let result = super::PersistedDataLight {
summary, summary,
chat_history, chat_history,
@@ -1172,6 +1207,7 @@ impl StorageAdapter for JsonlStorageAdapter {
signals, signals,
announcement_state, announcement_state,
goal_mode_state, goal_mode_state,
graph_mode_state,
}; };
tracing::info!( tracing::info!(
session_id = % info.id, num_chat_messages = result.chat_history.len(), session_id = % info.id, num_chat_messages = result.chat_history.len(),
@@ -2751,3 +2751,47 @@ async fn load_session_without_updates_survives_merged_chat_line() {
"resume succeeds; only the merged record is dropped" "resume succeeds; only the merged record is dropped"
); );
} }
#[tokio::test]
async fn graph_mode_state_round_trips_and_tombstones() {
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
use crate::session::graph_tracker::{GraphNode, GraphOrchestration, NodeStatus};
let tmp = TempDir::new().unwrap();
let adapter = JsonlStorageAdapter::with_root(tmp.path().to_path_buf());
let info = create_test_info();
adapter.init_session(&info, default_model_id()).await.unwrap();
let state = GraphOrchestration {
graph_id: "g-1".into(),
objective: "obj".into(),
status: GoalStatus::Active,
phase: GoalPhase::Executing,
plan_version: 1,
nodes: vec![GraphNode {
id: "gn-1".into(), title: "T".into(), spec: "S".into(), deps: vec![],
status: NodeStatus::Achieved, goal_id: Some("goal-1".into()),
rounds: 2, tokens_used: 42, failure: None,
}],
current_node: None,
created_at: "2026-07-20T00:00:00Z".into(),
elapsed_ms: 5,
token_budget: Some(100),
tokens_spent_nodes: 42,
history: vec![],
pause_message: None,
pending_discoveries: vec![],
replan_runs: 0,
};
adapter.write_graph_mode_state(&info, Some(&state)).await.unwrap();
let loaded = adapter.load_session_without_updates(&info).await.unwrap();
let got = loaded.graph_mode_state.expect("graph state must round-trip");
assert_eq!(got.graph_id, "g-1");
assert_eq!(got.nodes.len(), 1);
assert_eq!(got.nodes[0].status, NodeStatus::Achieved);
assert_eq!(got.nodes[0].tokens_used, 42);
assert_eq!(got.token_budget, Some(100));
// Tombstone removes the file; a second tombstone is not an error.
adapter.write_graph_mode_state(&info, None).await.unwrap();
adapter.write_graph_mode_state(&info, None).await.unwrap();
let after = adapter.load_session_without_updates(&info).await.unwrap();
assert!(after.graph_mode_state.is_none(), "cleared graph must not resurrect");
}
@@ -271,6 +271,8 @@ pub struct PersistedData {
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>, pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
/// Persisted goal mode orchestration state (None for sessions without goal mode) /// Persisted goal mode orchestration state (None for sessions without goal mode)
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>, pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
/// Persisted graph mode orchestration state (None for sessions without graph mode)
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
} }
/// Persisted data WITHOUT updates - for memory-efficient session loading /// Persisted data WITHOUT updates - for memory-efficient session loading
@@ -288,6 +290,8 @@ pub struct PersistedDataLight {
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>, pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
/// Persisted goal mode orchestration state (None for sessions without goal mode) /// Persisted goal mode orchestration state (None for sessions without goal mode)
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>, pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
/// Persisted graph mode orchestration state (None for sessions without graph mode)
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
} }
/// Result of copying session data /// Result of copying session data
@@ -595,6 +599,14 @@ pub trait StorageAdapter: Send + Sync {
state: &crate::session::goal_tracker::GoalOrchestration, state: &crate::session::goal_tracker::GoalOrchestration,
) -> io::Result<()>; ) -> io::Result<()>;
/// Write/update the graph mode orchestration state. `None` removes
/// the state file (tombstone after `/graph clear`).
async fn write_graph_mode_state(
&self,
info: &Info,
state: Option<&crate::session::graph_tracker::GraphOrchestration>,
) -> io::Result<()>;
/// Load all persisted data for a session /// Load all persisted data for a session
async fn load_session(&self, info: &Info) -> io::Result<PersistedData>; async fn load_session(&self, info: &Info) -> io::Result<PersistedData>;
@@ -0,0 +1,32 @@
You are a Graph Node Verifier for the Kigi harness: an adversarial skeptic
judging whether ONE node's outcome contract holds in the CURRENT state of
your working directory (the implementer's isolated worktree).
Do not trust the implementer's claims — re-run the decisive checks yourself
with your tools (read the code, run the tests/commands the contract
implies). An unverifiable claim is a gap. Missing evidence is a gap. Do NOT
modify any file — you are read-only by contract.
Your final message MUST end with exactly one of:
```
NODE_VERDICT: achieved
```
when every part of the node contract observably holds, or
```
NODE_VERDICT: not_achieved
GAPS:
- <one concrete, actionable gap per line>
```
Be strict but fair: judge ONLY this node's contract, not sibling nodes'
scope and not style preferences.
If you notice NECESSARY work that lies OUTSIDE this node's contract, it
is NOT a gap — do not fail the node for it. Report it instead, each item
on its own line before your verdict:
```
DISCOVERED: <one-line description of the out-of-scope work>
```
@@ -0,0 +1,39 @@
You are a Graph Node Worker for the Kigi harness: the implementer of ONE
node in a larger dependency graph. Sibling nodes are handled elsewhere —
complete ONLY this node's scope; nothing more, nothing less.
Your working directory is an isolated git worktree. Every change you make
here is merged back into the main tree once the node passes verification,
so work only inside it and leave it in a clean, coherent state.
Rules:
- Produce real, verifiable work. Run the builds/tests/commands you claim
pass; never fabricate evidence.
- If a GAPS section appears below, a verifier rejected the previous round —
close exactly those gaps first, then re-check the whole node contract.
- Do not commit; the harness owns version control.
- If you find NECESSARY work outside this node's contract (a missing
prerequisite, a broken sibling area, follow-up the objective implies),
do NOT do it. Report each item on its own line, anywhere in your final
message:
```
DISCOVERED: <one-line description of the out-of-scope work>
```
The harness turns these into new graph nodes.
Your final message MUST end with exactly one of:
```
NODE_RESULT: done
```
followed by a short factual summary of what exists now and how you verified
it (the verifier audits this), or
```
NODE_RESULT: blocked
```
followed by the precise reason this node cannot be completed in this
environment. Blocked is a FAILURE signal — never put success text there.
@@ -0,0 +1,54 @@
You are the Graph Topology Optimizer for the Kigi harness. You run at a
plan boundary (right after planning or replanning, never mid-execution).
Your job: make the PENDING part of the graph faster and sharper with the
FEWEST possible edits — or none.
## Inputs (below this prompt)
- OBJECTIVE: the overall graph objective, verbatim.
- CURRENT GRAPH: the nodes as JSON (id, title, spec, status, deps).
- EXECUTION HISTORY: recent graph events (rounds, failures), if any.
## What you may do — ONLY on nodes whose status is "Waiting" or "Ready"
- `remove_dep`: delete a FALSE dependency (B does not truly need A's
output) to restore parallelism. This is the highest-value edit.
- `reorder`: change the relative priority of pending nodes (the serial
scheduler picks the first Ready node in storage order).
- `merge`: fold two tiny, tightly-coupled pending nodes into one.
- `split`: break one oversized pending node into 2-3 focused nodes.
You may NEVER touch Running, Achieved, Failed, or Blocked nodes, the
`gn-final` terminal node, or dependencies ON immutable nodes that
represent real ordering. When in doubt, do nothing: an unnecessary edit
is worse than none.
## Output contract — STRICT
Use your `{WRITE_TOOL}` tool to write JSON to `{GRAPH_FILE}`:
```
{
"ops": [
{"op": "remove_dep", "node": "gn-…", "dep": "gn-…"},
{"op": "reorder", "order": ["gn-…", "gn-…"]},
{"op": "merge", "into": "gn-…", "from": "gn-…"},
{"op": "split", "node": "gn-…", "replacements": [
{"id": "new-slug", "title": "…", "spec": "…", "deps": ["gn-…"]}
]}
]
}
```
- `reorder.order` lists ONLY pending node ids, in the desired relative
priority; unlisted nodes keep their positions.
- `split.replacements` follow the same slug rules as planning; they
inherit the split node's dependents automatically.
- When the graph is already good, write `{"ops": []}` — that is a
respected answer, not a failure.
Your terminal response must be exactly:
```
Done
```
@@ -0,0 +1,70 @@
You are the Graph Plan Writer for the Kigi harness. You run ONCE at graph
creation. Decompose the objective into a SMALL dependency graph (a DAG) of
nodes. Each node later executes as its own autonomous goal — with its own
plan, implementation loop, and adversarial verification — so every node must
be a coherent, independently completable, independently verifiable unit of
work. The user never sees this file — write for the harness.
## Inputs (below this prompt)
- OBJECTIVE: the user's overall objective, verbatim.
- CONTEXT: optional extra snippet (usually empty; on a retry it carries the
validation errors your previous output failed — fix exactly those).
Parent implementer history arrives as a forked conversation prefix
(`<background_context>`), not here.
Inspect the workspace with your `{READ_TOOL}`/`{SEARCH_TOOL}`/`{LIST_TOOL}`
tools to ground the decomposition in what actually exists. Do NOT modify the
workspace; your only write is `{GRAPH_FILE}`.
## Decomposition rules
- 2-8 nodes, each sized to be completable in one focused autonomous run.
Prefer FEWER, larger nodes over many fragments: every node pays a full
plan + verify cycle.
- A dependency means "this node CANNOT EVEN START until that node is
Achieved". Only true ordering constraints — a false dependency serializes
work that could run independently. Independent nodes simply omit deps.
- Do NOT add a final whole-objective verification node: the harness appends
one automatically, depending on every node you write.
- Each `spec` is an OUTCOME contract for that node alone, in the OBJECTIVE's
own vocabulary: what must observably exist/hold when the node is done,
never how to structure the code. The node's own planner will derive
acceptance criteria from it — give it enough precision to do so.
- Preserve the OBJECTIVE's must-have terms verbatim across the specs; never
swap a named technique, technology, or artifact for an easier one.
- Scope the union of all specs to exactly the OBJECTIVE: no invented scope,
and no silently dropped requirement — every OBJECTIVE requirement must be
covered by exactly one node's spec.
## Output contract — STRICT
Use your `{WRITE_TOOL}` tool to write JSON to `{GRAPH_FILE}` with EXACTLY
this shape (no comments, no trailing commas, no extra keys):
```
{
"nodes": [
{
"id": "short-kebab-slug",
"title": "One-line human title",
"spec": "Outcome contract for this node alone.",
"deps": ["slug-of-prerequisite"]
}
]
}
```
- `id`: unique per node, 1-64 chars of `[A-Za-z0-9_-]`.
- `deps`: ids of other nodes in this file; omit or use `[]` for roots; no
self-references, no cycles.
- List nodes in the order work would naturally proceed; the harness breaks
scheduling ties by your order.
Your terminal response must be exactly:
```
Done
```
No other text — the harness parses this token to detect completion.
@@ -0,0 +1,55 @@
You are the Graph Replanner for the Kigi harness. A running dependency
graph surfaced NEW out-of-scope work (DISCOVERIES below). Your job is to
extend the graph with the FEWEST additional nodes that cover exactly that
work — nothing else.
## Inputs (below this prompt)
- OBJECTIVE: the overall graph objective, verbatim.
- CURRENT GRAPH: the existing nodes as JSON (id, title, status, deps).
These are IMMUTABLE — you cannot edit, remove, or reorder them.
- DISCOVERIES: the queued out-of-scope items, each with the node id that
surfaced it.
## Rules
- Append-only: output ONLY new nodes. Merge related discoveries into one
node where a single coherent unit of work covers them.
- A discovery already covered by an existing non-terminal node's spec
needs NO new node — cover only genuine gaps. If nothing needs a new
node, still write a file with an empty check? NO — see the escape
hatch below.
- `deps` may reference EXISTING node ids (the `gn-…` strings from
CURRENT GRAPH) and/or other new nodes. Only true ordering constraints.
- Each new node MUST set `discovered_from` to the existing node id(s)
whose discoveries it covers.
- Specs are outcome contracts in the OBJECTIVE's vocabulary, sized for
one focused autonomous run.
## Output contract — STRICT
Use your `{WRITE_TOOL}` tool to write JSON to `{GRAPH_FILE}`:
```
{
"nodes": [
{
"id": "short-kebab-slug",
"title": "One-line human title",
"spec": "Outcome contract for this node alone.",
"deps": ["gn-existing-or-new-slug"],
"discovered_from": ["gn-originating-node"]
}
]
}
```
Escape hatch: when every discovery is already covered by existing nodes,
write `{"nodes": []}` — the harness treats an empty appendix as "nothing
to add" and drains the discoveries.
Your terminal response must be exactly:
```
Done
```
@@ -395,16 +395,6 @@ Show terminal capability detection and setup info — including color level, whi
Aliases: `/terminal-check`, `/terminal-info` Aliases: `/terminal-check`, `/terminal-info`
### `/release-notes`
View release notes for the current version.
```
/release-notes
```
Aliases: `/changelog`
### `/docs` ### `/docs`
Browse in-TUI How-to Guides, open online Build docs, or jump to a guide by title. Browse in-TUI How-to Guides, open online Build docs, or jump to a guide by title.
@@ -2,7 +2,7 @@
> **Status: alpha.** The schema below is versioned (`kigi_code.schema.version = v1`); > **Status: alpha.** The schema below is versioned (`kigi_code.schema.version = v1`);
> additive changes may occur without notice, renames/removals will bump the > additive changes may occur without notice, renames/removals will bump the
> version and be called out in the changelog. > version.
Kigi CLI can export usage **metrics** and **events** to your organization's Kigi CLI can export usage **metrics** and **events** to your organization's
own OpenTelemetry collector, so platform teams can monitor adoption, token own OpenTelemetry collector, so platform teams can monitor adoption, token
@@ -977,6 +977,37 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
XaiSessionUpdate::InteractionResolved { tool_call_id } => { XaiSessionUpdate::InteractionResolved { tool_call_id } => {
agent.dismiss_resolved_interaction(&tool_call_id) agent.dismiss_resolved_interaction(&tool_call_id)
} }
XaiSessionUpdate::GraphUpdated {
objective,
status,
total_nodes,
achieved_nodes,
failed_nodes,
running_nodes,
current_node_title,
token_budget,
tokens_spent,
pause_message,
..
} => {
if status == "cleared" {
agent.graph_state.take();
} else {
agent.graph_state = Some(crate::app::agent::GraphDisplayState {
objective,
status: crate::app::agent::GoalDisplayStatus::parse(&status),
total_nodes,
achieved_nodes,
failed_nodes,
running_nodes,
current_node_title,
token_budget,
tokens_spent,
pause_message,
});
}
true
}
_ => { _ => {
tracing::trace!( tracing::trace!(
"Ignoring {}: {:?}", "Ignoring {}: {:?}",
@@ -623,10 +623,6 @@ pub enum Action {
/// to config.toml). `/plan <desc>` uses `EnterPlanMode` instead /// to config.toml). `/plan <desc>` uses `EnterPlanMode` instead
/// because it also starts a turn. /// because it also starts a turn.
SetPlanMode(PlanModeKind), SetPlanMode(PlanModeKind),
/// Enter feedback mode (visual prompt change, not a send).
EnterFeedbackMode,
/// Send feedback text collected in feedback mode.
SendFeedback(String),
/// Enter remember mode (visual prompt change, not a send). /// Enter remember mode (visual prompt change, not a send).
EnterRememberMode, EnterRememberMode,
/// Send a remember note from # mode. Routes through LLM rewrite when a /// Send a remember note from # mode. Routes through LLM rewrite when a
@@ -1475,10 +1471,6 @@ pub enum Effect {
/// `SwitchModelComplete` so `IncompatibleAgent` can roll back. /// `SwitchModelComplete` so `IncompatibleAgent` can roll back.
prev_model_id: Option<acp::ModelId>, prev_model_id: Option<acp::ModelId>,
}, },
/// Fetch changelog from CDN (both markdown + structured JSON).
/// Runs off the render path via `spawn_blocking`. Result is cached
/// on `AppView` so `/release-notes` and the welcome screen share it.
FetchChangelog,
/// Persist memory modal fullscreen preference to `[hints]` in config.toml. /// Persist memory modal fullscreen preference to `[hints]` in config.toml.
PersistMemoryFullscreen { fullscreen: bool }, PersistMemoryFullscreen { fullscreen: bool },
/// Persist the project-picker opt-out to `[hints] project_picker_disabled`. /// Persist the project-picker opt-out to `[hints] project_picker_disabled`.
@@ -1717,12 +1709,6 @@ pub enum Effect {
FetchBundleStatus, FetchBundleStatus,
/// Fetch a bundled entry's raw content via `kigi/bundle/entry/get`. /// Fetch a bundled entry's raw content via `kigi/bundle/entry/get`.
FetchCatalogEntry { kind: String, name: String }, FetchCatalogEntry { kind: String, name: String },
/// Send feedback about the current session (fire-and-forget POST).
SendFeedback {
agent_id: AgentId,
session_id: acp::SessionId,
feedback_text: String,
},
/// Save a remember note to global MEMORY.md (async file write). /// Save a remember note to global MEMORY.md (async file write).
SaveMemoryNote { SaveMemoryNote {
agent_id: AgentId, agent_id: AgentId,
@@ -2140,11 +2126,6 @@ pub enum TaskResult {
/// rollback on `IncompatibleAgent`. /// rollback on `IncompatibleAgent`.
prev_model_id: Option<acp::ModelId>, prev_model_id: Option<acp::ModelId>,
}, },
/// Changelog fetched from CDN (both formats).
ChangelogFetched {
markdown: Option<String>,
entries: Vec<kigi_shell::util::changelog::ChangelogEntry>,
},
/// Cross-session prompt history loaded from ACP. /// Cross-session prompt history loaded from ACP.
PromptHistoryLoaded { PromptHistoryLoaded {
agent_id: AgentId, agent_id: AgentId,
@@ -2267,15 +2248,6 @@ pub enum TaskResult {
agent_id: AgentId, agent_id: AgentId,
error: String, error: String,
}, },
/// Feedback submitted successfully (fire-and-forget).
FeedbackComplete {
agent_id: AgentId,
},
/// Feedback submission failed.
FeedbackFailed {
agent_id: AgentId,
error: String,
},
/// Memory note saved to global MEMORY.md. /// Memory note saved to global MEMORY.md.
MemoryNoteSaved { MemoryNoteSaved {
agent_id: AgentId, agent_id: AgentId,
+19
View File
@@ -385,6 +385,25 @@ impl GoalDisplayPhase {
} }
} }
} }
/// Graph mode display state — the pager-side mirror of the
/// `GraphUpdated` session notification (see the shell's
/// `extensions/notification.rs`). Deliberately lean: the status chip
/// shows counts + the current node; details live in `/graph status`.
#[derive(Debug, Clone)]
pub struct GraphDisplayState {
pub objective: String,
/// Reuses the goal display-status vocabulary (same wire strings).
pub status: GoalDisplayStatus,
pub total_nodes: u32,
pub achieved_nodes: u32,
pub failed_nodes: u32,
pub running_nodes: u32,
pub current_node_title: Option<String>,
pub token_budget: Option<i64>,
pub tokens_spent: i64,
pub pause_message: Option<String>,
}
/// Display state for an active goal, populated from `GoalUpdated` /// Display state for an active goal, populated from `GoalUpdated`
/// session notifications emitted by the goal orchestrator. /// session notifications emitted by the goal orchestrator.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -287,8 +287,6 @@ pub enum PromptInputMode {
Normal, Normal,
/// Bash mode (`!` prefix): Enter sends `Action::SendBashCommand`. /// Bash mode (`!` prefix): Enter sends `Action::SendBashCommand`.
Bash, Bash,
/// Feedback mode (`~` prefix, teal accent): Enter sends `Action::SendFeedback`.
Feedback,
/// Remember mode (`#` prefix, green accent): Enter sends `Action::SendRememberNote`. /// Remember mode (`#` prefix, green accent): Enter sends `Action::SendRememberNote`.
Remember, Remember,
} }
@@ -297,7 +295,6 @@ impl PromptInputMode {
match self { match self {
PromptInputMode::Normal => None, PromptInputMode::Normal => None,
PromptInputMode::Bash => Some(theme.command), PromptInputMode::Bash => Some(theme.command),
PromptInputMode::Feedback => Some(theme.accent_feedback),
PromptInputMode::Remember => Some(theme.accent_remember), PromptInputMode::Remember => Some(theme.accent_remember),
} }
} }
@@ -305,14 +302,12 @@ impl PromptInputMode {
match self { match self {
PromptInputMode::Normal => None, PromptInputMode::Normal => None,
PromptInputMode::Bash => Some(("! ", theme.command)), PromptInputMode::Bash => Some(("! ", theme.command)),
PromptInputMode::Feedback => Some(("~ ", theme.accent_feedback)),
PromptInputMode::Remember => Some(("# ", theme.accent_remember)), PromptInputMode::Remember => Some(("# ", theme.accent_remember)),
} }
} }
pub fn placeholder_override(self, multiline: bool) -> Option<&'static str> { pub fn placeholder_override(self, multiline: bool) -> Option<&'static str> {
match self { match self {
PromptInputMode::Normal | PromptInputMode::Bash => None, PromptInputMode::Normal | PromptInputMode::Bash => None,
PromptInputMode::Feedback => Some("Type your feedback..."),
PromptInputMode::Remember => { PromptInputMode::Remember => {
if multiline { if multiline {
Some("Save a memory note... (Enter for newline, Shift+Enter to save)") Some("Save a memory note... (Enter for newline, Shift+Enter to save)")
@@ -326,7 +321,6 @@ impl PromptInputMode {
match self { match self {
PromptInputMode::Normal => None, PromptInputMode::Normal => None,
PromptInputMode::Bash => Some("Run shell command"), PromptInputMode::Bash => Some("Run shell command"),
PromptInputMode::Feedback => Some("Send feedback"),
PromptInputMode::Remember => Some("Save memory note"), PromptInputMode::Remember => Some("Save memory note"),
} }
} }
@@ -334,7 +328,6 @@ impl PromptInputMode {
match self { match self {
PromptInputMode::Normal => Action::SendPrompt(text), PromptInputMode::Normal => Action::SendPrompt(text),
PromptInputMode::Bash => Action::SendBashCommand(text), PromptInputMode::Bash => Action::SendBashCommand(text),
PromptInputMode::Feedback => Action::SendFeedback(text),
PromptInputMode::Remember => Action::SendRememberNote(text), PromptInputMode::Remember => Action::SendRememberNote(text),
} }
} }
@@ -351,7 +344,6 @@ impl PromptInputMode {
|| ctrl_u || ctrl_u
|| ctrl_c || ctrl_c
} }
PromptInputMode::Feedback => key.code == KeyCode::Backspace || key.code == KeyCode::Esc,
} }
} }
} }
@@ -753,6 +745,9 @@ pub struct AgentView {
/// Current goal orchestration state. Set by `GoalUpdated` session /// Current goal orchestration state. Set by `GoalUpdated` session
/// notifications, cleared when a new session starts. /// notifications, cleared when a new session starts.
pub goal_state: Option<super::agent::GoalDisplayState>, pub goal_state: Option<super::agent::GoalDisplayState>,
/// Current graph orchestration state. Set by `GraphUpdated` session
/// notifications, cleared on the `"cleared"` sentinel / new session.
pub graph_state: Option<super::agent::GraphDisplayState>,
/// The consumed parked-wait marker slot for the current turn, if any. /// The consumed parked-wait marker slot for the current turn, if any.
/// Keyed by prompt id: a new turn naturally invalidates the slot with no /// Keyed by prompt id: a new turn naturally invalidates the slot with no
/// explicit clear site. See [`ParkedMarkerSlot`]. /// explicit clear site. See [`ParkedMarkerSlot`].
@@ -3078,10 +3073,6 @@ mod prompt_input_mode_tests {
PromptInputMode::Bash.accent_color(&theme), PromptInputMode::Bash.accent_color(&theme),
Some(theme.command) Some(theme.command)
); );
assert_eq!(
PromptInputMode::Feedback.accent_color(&theme),
Some(theme.accent_feedback)
);
assert_eq!( assert_eq!(
PromptInputMode::Remember.accent_color(&theme), PromptInputMode::Remember.accent_color(&theme),
Some(theme.accent_remember) Some(theme.accent_remember)
@@ -3095,10 +3086,6 @@ mod prompt_input_mode_tests {
PromptInputMode::Bash.prefix_override(&theme), PromptInputMode::Bash.prefix_override(&theme),
Some(("! ", theme.command)) Some(("! ", theme.command))
); );
assert_eq!(
PromptInputMode::Feedback.prefix_override(&theme),
Some(("~ ", theme.accent_feedback))
);
assert_eq!( assert_eq!(
PromptInputMode::Remember.prefix_override(&theme), PromptInputMode::Remember.prefix_override(&theme),
Some(("# ", theme.accent_remember)) Some(("# ", theme.accent_remember))
@@ -3110,14 +3097,6 @@ mod prompt_input_mode_tests {
assert_eq!(PromptInputMode::Normal.placeholder_override(true), None); assert_eq!(PromptInputMode::Normal.placeholder_override(true), None);
assert_eq!(PromptInputMode::Bash.placeholder_override(false), None); assert_eq!(PromptInputMode::Bash.placeholder_override(false), None);
assert_eq!(PromptInputMode::Bash.placeholder_override(true), None); assert_eq!(PromptInputMode::Bash.placeholder_override(true), None);
assert_eq!(
PromptInputMode::Feedback.placeholder_override(false),
Some("Type your feedback...")
);
assert_eq!(
PromptInputMode::Feedback.placeholder_override(true),
Some("Type your feedback...")
);
assert_eq!( assert_eq!(
PromptInputMode::Remember.placeholder_override(false), PromptInputMode::Remember.placeholder_override(false),
Some("Save a memory note... (Shift+Enter for multiline)") Some("Save a memory note... (Shift+Enter for multiline)")
@@ -3134,10 +3113,6 @@ mod prompt_input_mode_tests {
PromptInputMode::Bash.prompt_info_override(), PromptInputMode::Bash.prompt_info_override(),
Some("Run shell command") Some("Run shell command")
); );
assert_eq!(
PromptInputMode::Feedback.prompt_info_override(),
Some("Send feedback")
);
assert_eq!( assert_eq!(
PromptInputMode::Remember.prompt_info_override(), PromptInputMode::Remember.prompt_info_override(),
Some("Save memory note") Some("Save memory note")
@@ -3151,9 +3126,6 @@ mod prompt_input_mode_tests {
let t2 = "ls -l".to_string(); let t2 = "ls -l".to_string();
assert!(matches!(PromptInputMode::Bash.send_action(t2.clone()), assert!(matches!(PromptInputMode::Bash.send_action(t2.clone()),
Action::SendBashCommand(t) if t == t2)); Action::SendBashCommand(t) if t == t2));
let t3 = "this is feedback".to_string();
assert!(matches!(PromptInputMode::Feedback.send_action(t3.clone()),
Action::SendFeedback(t) if t == t3));
let t4 = "remember this".to_string(); let t4 = "remember this".to_string();
assert!(matches!(PromptInputMode::Remember.send_action(t4.clone()), assert!(matches!(PromptInputMode::Remember.send_action(t4.clone()),
Action::SendRememberNote(t) if t == t4)); Action::SendRememberNote(t) if t == t4));
@@ -3182,15 +3154,4 @@ mod prompt_input_mode_tests {
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))); assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
} }
} }
#[test]
fn is_exit_key_feedback_uses_stricter_set() {
let mode = PromptInputMode::Feedback;
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)));
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)));
}
} }
@@ -868,10 +868,9 @@ impl AgentView {
{ {
self.prompt.history_search.deactivate(); self.prompt.history_search.deactivate();
// Detect `! ` prefix to restore bash mode. Refined: only reset to Normal // Detect `! ` prefix to restore bash mode. Refined: only reset to Normal
// if currently in Bash (preserve Feedback/Remember if active). The ! prefix // if currently in Bash (preserve Remember if active). The ! prefix
// restore only applies when not in Feedback/Remember. // restore only applies when not in Remember.
if self.prompt_input_mode != PromptInputMode::Feedback if self.prompt_input_mode != PromptInputMode::Remember
&& self.prompt_input_mode != PromptInputMode::Remember
&& let Some(cmd) = text.strip_prefix("! ") && let Some(cmd) = text.strip_prefix("! ")
{ {
self.prompt_input_mode = PromptInputMode::Bash; self.prompt_input_mode = PromptInputMode::Bash;
@@ -1243,6 +1243,13 @@ impl AgentView {
), ),
); );
} }
if let Some(ref graph) = self.graph_state {
let tick = self.tasks.tick_count() as usize;
status.push(
"graph",
crate::views::agent_status::graph_status_line(graph, &theme, tick),
);
}
if let Some(mcp_line) = self.mcp_init_progress.as_ref().and_then(|p| { if let Some(mcp_line) = self.mcp_init_progress.as_ref().and_then(|p| {
crate::views::agent_status::mcp_status_line(p, self.scrollback.animation_tick(), &theme) crate::views::agent_status::mcp_status_line(p, self.scrollback.animation_tick(), &theme)
}) { }) {
@@ -93,6 +93,7 @@ impl AgentView {
chat_kind: false, chat_kind: false,
app_chat_mode: false, app_chat_mode: false,
goal_state: None, goal_state: None,
graph_state: None,
parked_wait_marker_for: None, parked_wait_marker_for: None,
end_work_announced: false, end_work_announced: false,
pending_stop_hooks: None, pending_stop_hooks: None,
+16 -133
View File
@@ -596,12 +596,6 @@ pub struct AppView {
/// Release-safe FPS HUD (`/debug fps`; `KIGI_FPS` env on release /// Release-safe FPS HUD (`/debug fps`; `KIGI_FPS` env on release
/// builds, where the dev overlay is compiled out) — see the module doc. /// builds, where the dev overlay is compiled out) — see the module doc.
pub fps_hud: crate::views::fps_hud::FpsHud, pub fps_hud: crate::views::fps_hud::FpsHud,
/// Cached changelog markdown (for `/release-notes`). Populated by
/// `FetchChangelog` at startup; `None` until the fetch completes.
pub changelog_markdown: Option<String>,
/// Cached changelog bullets (for welcome screen). Populated by
/// `FetchChangelog` at startup; empty until the fetch completes.
pub changelog_bullets: Vec<String>,
/// Resolved tip list from config layers. /// Resolved tip list from config layers.
pub tips: Vec<String>, pub tips: Vec<String>,
/// Selected tip for the current launch/session. /// Selected tip for the current launch/session.
@@ -709,10 +703,6 @@ pub struct AppView {
pub welcome_menu_index: Option<usize>, pub welcome_menu_index: Option<usize>,
/// Hit-test rects for welcome menu items (populated during render). /// Hit-test rects for welcome menu items (populated during render).
pub welcome_menu_rects: Vec<ratatui::layout::Rect>, pub welcome_menu_rects: Vec<ratatui::layout::Rect>,
/// Whether the welcome menu currently includes a "Changelog" row (above
/// Quit). Set during render; the input handler uses it to size the menu and
/// map the extra row to the release-notes action.
pub welcome_show_changelog_action: bool,
/// Hit-test rect for the import-claude banner on the welcome screen. /// Hit-test rect for the import-claude banner on the welcome screen.
pub welcome_import_banner_rect: Option<ratatui::layout::Rect>, pub welcome_import_banner_rect: Option<ratatui::layout::Rect>,
/// Last known mouse position (column, row), updated on every Mouse event. /// Last known mouse position (column, row), updated on every Mouse event.
@@ -734,14 +724,8 @@ pub struct AppView {
pub welcome_auth_url_rect: Option<ratatui::layout::Rect>, pub welcome_auth_url_rect: Option<ratatui::layout::Rect>,
/// Whether the mouse pointer was last over the auth URL (for OSC 22 cursor shape). /// Whether the mouse pointer was last over the auth URL (for OSC 22 cursor shape).
pub welcome_on_auth_url: bool, pub welcome_on_auth_url: bool,
/// Mouse last over the changelog block (drives hover color + redraws).
pub welcome_on_changelog_cta: bool,
/// Hit-test rect for the "show full URL" fallback link. /// Hit-test rect for the "show full URL" fallback link.
pub welcome_auth_fallback_rect: Option<ratatui::layout::Rect>, pub welcome_auth_fallback_rect: Option<ratatui::layout::Rect>,
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
/// Hit-test rect for the gate URL link on the paywall CTA.
/// Hit-test rect for the clickable changelog info block (opens release notes).
pub welcome_changelog_cta_rect: Option<ratatui::layout::Rect>,
/// Show the raw auth URL with mouse capture disabled for manual copy. /// Show the raw auth URL with mouse capture disabled for manual copy.
pub auth_show_raw_url: bool, pub auth_show_raw_url: bool,
/// Whether mouse capture is currently disabled for raw URL mode. /// Whether mouse capture is currently disabled for raw URL mode.
@@ -1015,8 +999,6 @@ impl AppView {
tracing_rx: None, tracing_rx: None,
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(), scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
fps_hud: crate::views::fps_hud::FpsHud::new(), fps_hud: crate::views::fps_hud::FpsHud::new(),
changelog_markdown: None,
changelog_bullets: Vec::new(),
tips: Vec::new(), tips: Vec::new(),
tip: None, tip: None,
welcome_prompt, welcome_prompt,
@@ -1031,7 +1013,6 @@ impl AppView {
minimal_state: crate::minimal_api::MinimalState::default(), minimal_state: crate::minimal_api::MinimalState::default(),
welcome_menu_index: None, welcome_menu_index: None,
welcome_menu_rects: Vec::new(), welcome_menu_rects: Vec::new(),
welcome_show_changelog_action: false,
welcome_import_banner_rect: None, welcome_import_banner_rect: None,
last_mouse_pos: None, last_mouse_pos: None,
last_scroll_pos: None, last_scroll_pos: None,
@@ -1039,9 +1020,7 @@ impl AppView {
welcome_prompt_rect: None, welcome_prompt_rect: None,
welcome_auth_url_rect: None, welcome_auth_url_rect: None,
welcome_on_auth_url: false, welcome_on_auth_url: false,
welcome_on_changelog_cta: false,
welcome_auth_fallback_rect: None, welcome_auth_fallback_rect: None,
welcome_changelog_cta_rect: None,
auth_show_raw_url: false, auth_show_raw_url: false,
auth_mouse_disabled: false, auth_mouse_disabled: false,
session_picker_entries: None, session_picker_entries: None,
@@ -1635,19 +1614,11 @@ impl AppView {
new_worktree_dialog: &mut self.new_worktree_dialog, new_worktree_dialog: &mut self.new_worktree_dialog,
menu_index: &mut self.welcome_menu_index, menu_index: &mut self.welcome_menu_index,
menu_rects: &self.welcome_menu_rects, menu_rects: &self.welcome_menu_rects,
menu_count: 3 menu_count: 3 + if self.has_claude_import { 1 } else { 0 },
+ if self.has_claude_import { 1 } else { 0 }
+ if self.welcome_show_changelog_action {
1
} else {
0
},
prompt_rect: self.welcome_prompt_rect.as_ref(), prompt_rect: self.welcome_prompt_rect.as_ref(),
import_banner_rect: self.welcome_import_banner_rect.as_ref(), import_banner_rect: self.welcome_import_banner_rect.as_ref(),
auth_url_rect: self.welcome_auth_url_rect.as_ref(), auth_url_rect: self.welcome_auth_url_rect.as_ref(),
auth_fallback_rect: self.welcome_auth_fallback_rect.as_ref(), auth_fallback_rect: self.welcome_auth_fallback_rect.as_ref(),
changelog_cta_rect: self.welcome_changelog_cta_rect.as_ref(),
on_changelog_cta: &mut self.welcome_on_changelog_cta,
show_raw_url: &mut self.auth_show_raw_url, show_raw_url: &mut self.auth_show_raw_url,
sp_entries: &mut self.session_picker_entries, sp_entries: &mut self.session_picker_entries,
sp_state: &mut self.session_picker_state, sp_state: &mut self.session_picker_state,
@@ -1657,8 +1628,6 @@ impl AppView {
has_claude_import: self.has_claude_import, has_claude_import: self.has_claude_import,
import_claude_modal: &mut self.import_claude_modal, import_claude_modal: &mut self.import_claude_modal,
welcome_doc_viewer: &mut self.welcome_doc_viewer, welcome_doc_viewer: &mut self.welcome_doc_viewer,
changelog_markdown: &self.changelog_markdown,
show_changelog_action: self.welcome_show_changelog_action,
has_pending_update: self.pending_update_version.is_some(), has_pending_update: self.pending_update_version.is_some(),
has_foreign_resume, has_foreign_resume,
cwd_has_git_ancestor: self.cwd_has_git_ancestor, cwd_has_git_ancestor: self.cwd_has_git_ancestor,
@@ -2175,10 +2144,6 @@ struct WelcomeInputCtx<'a> {
import_banner_rect: Option<&'a ratatui::layout::Rect>, import_banner_rect: Option<&'a ratatui::layout::Rect>,
auth_url_rect: Option<&'a ratatui::layout::Rect>, auth_url_rect: Option<&'a ratatui::layout::Rect>,
auth_fallback_rect: Option<&'a ratatui::layout::Rect>, auth_fallback_rect: Option<&'a ratatui::layout::Rect>,
/// Hit-test rect for the clickable changelog info block (opens release notes).
changelog_cta_rect: Option<&'a ratatui::layout::Rect>,
/// Sticky hover flag for the changelog block (redraw on enter/leave).
on_changelog_cta: &'a mut bool,
show_raw_url: &'a mut bool, show_raw_url: &'a mut bool,
sp_entries: &'a mut Option<Vec<SessionPickerEntry>>, sp_entries: &'a mut Option<Vec<SessionPickerEntry>>,
sp_state: &'a mut crate::views::picker::PickerState, sp_state: &'a mut crate::views::picker::PickerState,
@@ -2190,10 +2155,6 @@ struct WelcomeInputCtx<'a> {
has_claude_import: bool, has_claude_import: bool,
import_claude_modal: &'a mut Option<crate::views::import_claude_modal::ImportClaudeModalState>, import_claude_modal: &'a mut Option<crate::views::import_claude_modal::ImportClaudeModalState>,
welcome_doc_viewer: &'a mut Option<crate::views::modal::ActiveModal>, welcome_doc_viewer: &'a mut Option<crate::views::modal::ActiveModal>,
changelog_markdown: &'a Option<String>,
/// Whether the welcome menu currently includes a "Changelog" row (above
/// Quit), so index→action mapping accounts for it.
show_changelog_action: bool,
has_pending_update: bool, has_pending_update: bool,
/// A recent foreign session is available to resume when no update is pending. /// A recent foreign session is available to resume when no update is pending.
has_foreign_resume: bool, has_foreign_resume: bool,
@@ -2601,12 +2562,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
if key!(Enter).matches(key) if key!(Enter).matches(key)
&& let Some(idx) = *ctx.menu_index && let Some(idx) = *ctx.menu_index
{ {
return dispatch_menu_action( return dispatch_menu_action(idx, ctx.has_claude_import);
idx,
ctx.has_claude_import,
ctx.show_changelog_action,
ctx.changelog_markdown.as_deref(),
);
} }
if crate::input::key::is_text_input_key(key) { if crate::input::key::is_text_input_key(key) {
*ctx.prompt_focused = true; *ctx.prompt_focused = true;
@@ -2762,23 +2718,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
{ {
return InputOutcome::Action(Action::DismissClaudeImport); return InputOutcome::Action(Action::DismissClaudeImport);
} }
return dispatch_menu_action( return dispatch_menu_action(i, ctx.has_claude_import);
i,
ctx.has_claude_import,
ctx.show_changelog_action,
ctx.changelog_markdown.as_deref(),
);
} }
} }
if let Some(rect) = ctx.changelog_cta_rect
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
&& let Some(md) = ctx.changelog_markdown.as_deref()
{
return InputOutcome::Action(Action::ShowReleaseNotes {
title: "Release Notes".to_string(),
content: md.trim().to_string(),
});
}
if let Some(rect) = ctx.auth_url_rect if let Some(rect) = ctx.auth_url_rect
&& matches!(ctx.auth_state, AuthState::Authenticating { .. }) && matches!(ctx.auth_state, AuthState::Authenticating { .. })
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
@@ -2830,12 +2772,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
if ctx.has_claude_import && new_index == Some(0) { if ctx.has_claude_import && new_index == Some(0) {
return InputOutcome::Changed; return InputOutcome::Changed;
} }
let pos = ratatui::layout::Position::new(mouse.column, mouse.row);
let over_cta = ctx.changelog_cta_rect.is_some_and(|r| r.contains(pos));
if over_cta != *ctx.on_changelog_cta {
*ctx.on_changelog_cta = over_cta;
return InputOutcome::Changed;
}
if matches!(ctx.auth_state, AuthState::Authenticating { .. }) if matches!(ctx.auth_state, AuthState::Authenticating { .. })
&& ctx.auth_url_rect.is_some() && ctx.auth_url_rect.is_some()
{ {
@@ -2885,23 +2821,12 @@ fn dispatch_pending_menu_action(items: &[PendingMenuItem], index: usize) -> Inpu
} }
/// Dispatch an action for a welcome menu item by index. /// Dispatch an action for a welcome menu item by index.
/// ///
/// Menu order: `[Import]`, New worktree, Resume session, `[Changelog]`, Quit. /// Menu order: `[Import]`, New worktree, Resume session, Quit.
/// `show_changelog_action` is true when the Changelog row is rendered; release fn dispatch_menu_action(index: usize, has_claude_import: bool) -> InputOutcome {
/// notes open only once `changelog_md` is available.
fn dispatch_menu_action(
index: usize,
has_claude_import: bool,
show_changelog_action: bool,
changelog_md: Option<&str>,
) -> InputOutcome {
let base = if has_claude_import { 1 } else { 0 }; let base = if has_claude_import { 1 } else { 0 };
let worktree_idx = base; let worktree_idx = base;
let resume_idx = base + 1; let resume_idx = base + 1;
let (changelog_idx, quit_idx) = if show_changelog_action { let quit_idx = base + 2;
(Some(base + 2), base + 3)
} else {
(None, base + 2)
};
if has_claude_import && index == 0 { if has_claude_import && index == 0 {
return InputOutcome::Action(Action::ImportClaudeSettings); return InputOutcome::Action(Action::ImportClaudeSettings);
} }
@@ -2911,15 +2836,6 @@ fn dispatch_menu_action(
if index == resume_idx { if index == resume_idx {
return InputOutcome::Action(Action::FetchSessionList); return InputOutcome::Action(Action::FetchSessionList);
} }
if Some(index) == changelog_idx {
if let Some(md) = changelog_md {
return InputOutcome::Action(Action::ShowReleaseNotes {
title: "Release Notes".to_string(),
content: md.trim().to_string(),
});
}
return InputOutcome::Unchanged;
}
if index == quit_idx { if index == quit_idx {
return InputOutcome::Action(Action::Quit); return InputOutcome::Action(Action::Quit);
} }
@@ -3264,8 +3180,6 @@ impl AppView {
session_picker_source_filter: self.session_picker_source_filter, session_picker_source_filter: self.session_picker_source_filter,
chat_mode: self.chat_mode, chat_mode: self.chat_mode,
is_api_key_auth: self.is_api_key_auth, is_api_key_auth: self.is_api_key_auth,
changelog_bullets: &self.changelog_bullets,
changelog_has_full_notes: self.changelog_markdown.is_some(),
}; };
let result = crate::views::welcome::render_welcome( let result = crate::views::welcome::render_welcome(
view_area, view_area,
@@ -3275,12 +3189,10 @@ impl AppView {
&mut self.session_picker_state, &mut self.session_picker_state,
); );
self.welcome_menu_rects = result.menu_rects; self.welcome_menu_rects = result.menu_rects;
self.welcome_show_changelog_action = result.changelog_action_present;
self.welcome_prompt_rect = result.prompt_rect; self.welcome_prompt_rect = result.prompt_rect;
self.welcome_import_banner_rect = result.import_banner_rect; self.welcome_import_banner_rect = result.import_banner_rect;
self.welcome_auth_url_rect = result.auth_url_rect; self.welcome_auth_url_rect = result.auth_url_rect;
self.welcome_auth_fallback_rect = result.auth_fallback_rect; self.welcome_auth_fallback_rect = result.auth_fallback_rect;
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
self.session_picker_state.hit_areas = result.session_picker_hit_areas; self.session_picker_state.hit_areas = result.session_picker_hit_areas;
if let Some(modal) = self.import_claude_modal.as_mut() { if let Some(modal) = self.import_claude_modal.as_mut() {
let theme = crate::theme::Theme::current(); let theme = crate::theme::Theme::current();
@@ -4324,8 +4236,6 @@ pub(crate) mod tests {
pending_notification_escapes: None, pending_notification_escapes: None,
deferred_notification: None, deferred_notification: None,
tracing_rx: None, tracing_rx: None,
changelog_markdown: None,
changelog_bullets: Vec::new(),
tips: Vec::new(), tips: Vec::new(),
tip: None, tip: None,
cli_model_override: None, cli_model_override: None,
@@ -4379,7 +4289,6 @@ pub(crate) mod tests {
welcome_tip_typing_dismissed: false, welcome_tip_typing_dismissed: false,
welcome_menu_index: None, welcome_menu_index: None,
welcome_menu_rects: Vec::new(), welcome_menu_rects: Vec::new(),
welcome_show_changelog_action: false,
welcome_import_banner_rect: None, welcome_import_banner_rect: None,
last_mouse_pos: None, last_mouse_pos: None,
last_scroll_pos: None, last_scroll_pos: None,
@@ -4387,9 +4296,7 @@ pub(crate) mod tests {
welcome_prompt_rect: None, welcome_prompt_rect: None,
welcome_auth_url_rect: None, welcome_auth_url_rect: None,
welcome_on_auth_url: false, welcome_on_auth_url: false,
welcome_on_changelog_cta: false,
welcome_auth_fallback_rect: None, welcome_auth_fallback_rect: None,
welcome_changelog_cta_rect: None,
auth_show_raw_url: false, auth_show_raw_url: false,
auth_mouse_disabled: false, auth_mouse_disabled: false,
session_picker_entries: None, session_picker_entries: None,
@@ -5756,64 +5663,40 @@ pub(crate) mod tests {
); );
} }
#[test] #[test]
fn menu_action_indices_without_changelog() { fn menu_action_indices() {
assert!(matches!( assert!(matches!(
dispatch_menu_action(0, false, false, None), dispatch_menu_action(0, false),
InputOutcome::Action(Action::OpenNewWorktreeDialog) InputOutcome::Action(Action::OpenNewWorktreeDialog)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(1, false, false, None), dispatch_menu_action(1, false),
InputOutcome::Action(Action::FetchSessionList) InputOutcome::Action(Action::FetchSessionList)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(2, false, false, None), dispatch_menu_action(2, false),
InputOutcome::Action(Action::Quit) InputOutcome::Action(Action::Quit)
)); ));
}
#[test]
fn menu_action_changelog_sits_above_quit() {
let md = Some("# notes");
assert!(matches!( assert!(matches!(
dispatch_menu_action(1, false, true, md), dispatch_menu_action(3, false),
InputOutcome::Action(Action::FetchSessionList)
));
assert!(matches!(
dispatch_menu_action(2, false, true, md),
InputOutcome::Action(Action::ShowReleaseNotes { .. })
));
assert!(matches!(
dispatch_menu_action(3, false, true, md),
InputOutcome::Action(Action::Quit)
));
}
#[test]
fn menu_action_changelog_before_fetch_is_noop() {
assert!(matches!(
dispatch_menu_action(2, false, true, None),
InputOutcome::Unchanged InputOutcome::Unchanged
)); ));
} }
#[test] #[test]
fn menu_action_indices_with_import_and_changelog() { fn menu_action_indices_with_import() {
let md = Some("# notes");
assert!(matches!( assert!(matches!(
dispatch_menu_action(0, true, true, md), dispatch_menu_action(0, true),
InputOutcome::Action(Action::ImportClaudeSettings) InputOutcome::Action(Action::ImportClaudeSettings)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(1, true, true, md), dispatch_menu_action(1, true),
InputOutcome::Action(Action::OpenNewWorktreeDialog) InputOutcome::Action(Action::OpenNewWorktreeDialog)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(2, true, true, md), dispatch_menu_action(2, true),
InputOutcome::Action(Action::FetchSessionList) InputOutcome::Action(Action::FetchSessionList)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(3, true, true, md), dispatch_menu_action(3, true),
InputOutcome::Action(Action::ShowReleaseNotes { .. })
));
assert!(matches!(
dispatch_menu_action(4, true, true, md),
InputOutcome::Action(Action::Quit) InputOutcome::Action(Action::Quit)
)); ));
} }
@@ -387,9 +387,6 @@ pub(super) fn handle_auth_complete(
// status only; shell auto-syncs post-auth // status only; shell auto-syncs post-auth
let mut effects = dispatch(Action::RequestBundleStatus, app); let mut effects = dispatch(Action::RequestBundleStatus, app);
// Fetch changelog (mirrors startup path for interactive login).
effects.push(Effect::FetchChangelog);
// Replay deferred session startup once BOTH gates are open. Auth // Replay deferred session startup once BOTH gates are open. Auth
// is now Done, so `session_startup_allowed()` here means "is trust // is now Done, so `session_startup_allowed()` here means "is trust
// also resolved?" -- if trust is still Pending its question renders // also resolved?" -- if trust is still Pending its question renders
@@ -1,4 +1,4 @@
//! Feedback, remember-note, btw, and recap dispatchers. //! Remember-note, btw, and recap dispatchers.
use super::ctx::with_active_agent; use super::ctx::with_active_agent;
use crate::app::actions::Effect; use crate::app::actions::Effect;
@@ -18,16 +18,6 @@ fn next_rewrite_nonce() -> u64 {
REWRITE_NONCE.fetch_add(1, Ordering::Relaxed) REWRITE_NONCE.fetch_add(1, Ordering::Relaxed)
} }
/// Enter feedback mode: visual change to prompt bar (teal accent, pencil prefix).
/// No side effects — the user types feedback text and presses Enter to send.
pub(super) fn dispatch_enter_feedback_mode(app: &mut AppView) -> Vec<Effect> {
with_active_agent(app, |agent| {
agent.prompt_input_mode = PromptInputMode::Feedback;
agent.prompt.set_text("");
});
vec![]
}
/// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix). /// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix).
/// No side effects — the user types a memory note and presses Enter to send. /// No side effects — the user types a memory note and presses Enter to send.
pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> { pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
@@ -38,47 +28,6 @@ pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
vec![] vec![]
} }
/// Send feedback text to the server. Shows a thank-you message immediately
/// and fires the HTTP POST as a background effect.
pub(super) fn dispatch_send_feedback(app: &mut AppView, text: String) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
agent.prompt_input_mode = PromptInputMode::Normal;
agent.prompt.set_text("");
// Submitting feedback retires any edit-contextual ephemeral tip.
agent.ephemeral_tip.clear_on_submit();
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
agent.scrollback.push_block(RenderBlock::system(
"Please provide feedback text.".to_string(),
));
return vec![];
}
let Some(session_id) = agent.session.session_id.clone() else {
agent
.scrollback
.push_block(RenderBlock::system("No active session.".to_string()));
return vec![];
};
agent.scrollback.push_block(RenderBlock::system(
"Thanks for the feedback! The Kigi team is on it.".to_string(),
));
vec![Effect::SendFeedback {
agent_id: id,
session_id,
feedback_text: trimmed,
}]
}
/// Send a raw remember note for LLM-powered rewriting via `kigi/memory/rewrite`. /// Send a raw remember note for LLM-powered rewriting via `kigi/memory/rewrite`.
/// Clears remember mode and prompts the LLM to reformat the note with session /// Clears remember mode and prompts the LLM to reformat the note with session
/// context. Falls back to direct `SaveMemoryNote` when no session is available. /// context. Falls back to direct `SaveMemoryNote` when no session is available.
@@ -33,8 +33,7 @@ use super::modes::{
set_permission_mode, set_plan_mode, set_yolo_mode, set_permission_mode, set_plan_mode, set_yolo_mode,
}; };
use super::notes::{ use super::notes::{
dispatch_enter_feedback_mode, dispatch_enter_remember_mode, dispatch_enter_remember_mode, dispatch_save_remember_note_from_modal, dispatch_send_btw,
dispatch_save_remember_note_from_modal, dispatch_send_btw, dispatch_send_feedback,
dispatch_send_recap, dispatch_send_remember_note, dispatch_send_recap, dispatch_send_remember_note,
}; };
use super::permissions::{ use super::permissions::{
@@ -784,8 +783,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::ShowPlan => dispatch_show_plan(app), Action::ShowPlan => dispatch_show_plan(app),
Action::EnterPlanMode { description } => dispatch_enter_plan_mode(app, description), Action::EnterPlanMode { description } => dispatch_enter_plan_mode(app, description),
Action::SetPlanMode(kind) => set_plan_mode(app, kind), Action::SetPlanMode(kind) => set_plan_mode(app, kind),
Action::EnterFeedbackMode => dispatch_enter_feedback_mode(app),
Action::SendFeedback(text) => dispatch_send_feedback(app, text),
Action::EnterRememberMode => dispatch_enter_remember_mode(app), Action::EnterRememberMode => dispatch_enter_remember_mode(app),
Action::SendRememberNote(text) => dispatch_send_remember_note(app, text), Action::SendRememberNote(text) => dispatch_send_remember_note(app, text),
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app), Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
@@ -442,11 +442,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
} }
vec![] vec![]
} }
TaskResult::ChangelogFetched { markdown, entries } => {
app.changelog_markdown = markdown;
app.changelog_bullets = kigi_shell::util::changelog::bullets_from_entries(&entries, 3);
vec![]
}
TaskResult::ClipboardAttachmentProbed { TaskResult::ClipboardAttachmentProbed {
ctx, ctx,
image, image,
@@ -663,17 +658,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
} }
vec![] vec![]
} }
TaskResult::FeedbackComplete { .. } => vec![],
TaskResult::FeedbackFailed { agent_id, error } => {
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(format!(
"Couldn't send feedback: {error}"
)));
}
vec![]
}
TaskResult::MemoryNoteSaved { agent_id, result } => { TaskResult::MemoryNoteSaved { agent_id, result } => {
handle_memory_note_saved(app, agent_id, result) handle_memory_note_saved(app, agent_id, result)
} }
@@ -85,8 +85,6 @@ fn test_app() -> AppView {
pending_notification_escapes: None, pending_notification_escapes: None,
deferred_notification: None, deferred_notification: None,
tracing_rx: None, tracing_rx: None,
changelog_markdown: None,
changelog_bullets: Vec::new(),
tips: Vec::new(), tips: Vec::new(),
tip: None, tip: None,
cli_model_override: None, cli_model_override: None,
@@ -143,7 +141,6 @@ fn test_app() -> AppView {
welcome_tip_typing_dismissed: false, welcome_tip_typing_dismissed: false,
welcome_menu_index: None, welcome_menu_index: None,
welcome_menu_rects: Vec::new(), welcome_menu_rects: Vec::new(),
welcome_show_changelog_action: false,
welcome_import_banner_rect: None, welcome_import_banner_rect: None,
last_mouse_pos: None, last_mouse_pos: None,
last_scroll_pos: None, last_scroll_pos: None,
@@ -151,9 +148,7 @@ fn test_app() -> AppView {
welcome_prompt_rect: None, welcome_prompt_rect: None,
welcome_auth_url_rect: None, welcome_auth_url_rect: None,
welcome_on_auth_url: false, welcome_on_auth_url: false,
welcome_on_changelog_cta: false,
welcome_auth_fallback_rect: None, welcome_auth_fallback_rect: None,
welcome_changelog_cta_rect: None,
auth_show_raw_url: false, auth_show_raw_url: false,
auth_mouse_disabled: false, auth_mouse_disabled: false,
session_picker_entries: None, session_picker_entries: None,
@@ -32,23 +32,6 @@ fn seed_foreign_resume_hint(
}), }),
); );
} }
/// Sending feedback is a submit: it retires the active ephemeral tip.
#[test]
fn send_feedback_clears_active_ephemeral_tip() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let agent = app.agents.get_mut(&id).unwrap();
let _ = agent.ephemeral_tip.show(
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint")),
&mut std::collections::HashMap::new(),
);
assert!(agent.ephemeral_tip.is_active());
let _ = dispatch(Action::SendFeedback("it broke".into()), &mut app);
assert!(
!app.agents.get(&id).unwrap().ephemeral_tip.is_active(),
"feedback submit must clear the tip"
);
}
/// Sending a remember note is a submit: it retires the active ephemeral tip. /// Sending a remember note is a submit: it retires the active ephemeral tip.
#[test] #[test]
fn send_remember_note_clears_active_ephemeral_tip() { fn send_remember_note_clears_active_ephemeral_tip() {
@@ -1712,27 +1712,6 @@ pub(crate) fn execute(
TaskResult::PromptImagePreviewPrepared TaskResult::PromptImagePreviewPrepared
}); });
} }
Effect::FetchChangelog => {
tasks
.spawn(async move {
let changelog = tokio::task::spawn_blocking(|| {
kigi_shell::util::changelog::ChangelogManager::new()
.fetch()
})
.await
.unwrap_or_else(|e| {
tracing::warn!(error = % e, "changelog fetch task failed");
kigi_shell::util::changelog::Changelog {
markdown: None,
entries: None,
}
});
TaskResult::ChangelogFetched {
markdown: changelog.markdown,
entries: changelog.entries.unwrap_or_default(),
}
});
}
Effect::PersistMemoryFullscreen { fullscreen } => { Effect::PersistMemoryFullscreen { fullscreen } => {
persist_hint( persist_hint(
tasks, tasks,
@@ -2599,61 +2578,6 @@ pub(crate) fn execute(
} }
}); });
} }
Effect::SendFeedback { agent_id, session_id, feedback_text } => {
use kigi_shell::session::ClientType;
use kigi_shell::session::acp_types::ClientFeedbackInput;
let terminal_info = Some(
crate::terminal::terminal_context().feedback_info(),
);
let tx = acp_tx.clone();
tasks
.spawn(async move {
let input = ClientFeedbackInput {
session_id: session_id.0.to_string(),
client_type: ClientType::Tui,
rating_type: None,
rating_value: None,
feedback_text: Some(feedback_text),
feedback_categories: vec![],
context_type: None,
turn_number: None,
request_id: None,
client_version: Some(kigi_version::VERSION.to_string()),
metadata: None,
terminal_info,
};
let raw_params = match serde_json::value::to_raw_value(&input) {
Ok(v) => v,
Err(e) => {
return TaskResult::FeedbackFailed {
agent_id,
error: sanitize_user_error(
&format!("couldn't serialize feedback: {e}"),
),
};
}
};
let request = acp::ExtRequest::new(
"kigi/feedback",
raw_params.into(),
);
match acp_send(request, &tx).await {
Ok(_) => {
TaskResult::FeedbackComplete {
agent_id,
}
}
Err(e) => {
TaskResult::FeedbackFailed {
agent_id,
error: sanitize_user_error(
&format!("couldn't send feedback: {e}"),
),
}
}
}
});
}
Effect::RewriteMemoryNote { Effect::RewriteMemoryNote {
agent_id, agent_id,
session_id, session_id,
@@ -1136,12 +1136,6 @@ pub(crate) async fn run(
if process_effects(effs, &mut tasks, &mut app, &progress_tx) { if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
return Ok(make_run_result(&app)); return Ok(make_run_result(&app));
} }
// Fetch changelog off the render path so the welcome screen
// can display bullets and /release-notes uses the cached result.
let effs = vec![super::actions::Effect::FetchChangelog];
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
return Ok(make_run_result(&app));
}
} }
if !post_render_effects.is_empty() if !post_render_effects.is_empty()
+1 -2
View File
@@ -169,8 +169,7 @@ impl AgentView {
.map(str::to_owned) .map(str::to_owned)
{ {
self.prompt.history_search.deactivate(); self.prompt.history_search.deactivate();
if self.prompt_input_mode != PromptInputMode::Feedback if self.prompt_input_mode != PromptInputMode::Remember
&& self.prompt_input_mode != PromptInputMode::Remember
&& let Some(cmd) = text.strip_prefix("! ") && let Some(cmd) = text.strip_prefix("! ")
{ {
self.prompt_input_mode = PromptInputMode::Bash; self.prompt_input_mode = PromptInputMode::Bash;
@@ -1,9 +1,14 @@
//! `/feedback` -- send session feedback. //! `/feedback` -- open the Kigi GitHub issues page.
use crate::app::actions::Action; use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Send session feedback inline or enter feedback mode. /// Where feedback goes: the project's own issue tracker. Kigi is a community
/// build, so its feedback belongs on its GitHub repo — mirroring the official
/// kimi-cli, whose `/feedback` opens its repo's issues page.
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
/// Open the Kigi issue tracker in the browser.
pub struct FeedbackCommand; pub struct FeedbackCommand;
impl SlashCommand for FeedbackCommand { impl SlashCommand for FeedbackCommand {
@@ -12,27 +17,75 @@ impl SlashCommand for FeedbackCommand {
} }
fn description(&self) -> &str { fn description(&self) -> &str {
"Send feedback about the current session" "Report feedback on the Kigi GitHub issues page"
} }
fn usage(&self) -> &str { fn usage(&self) -> &str {
"/feedback [text]" "/feedback"
} }
fn takes_args(&self) -> bool { fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
true CommandResult::Action(Action::OpenUrl(FEEDBACK_ISSUES_URL.into()))
}
} }
fn arg_placeholder(&self) -> Option<&str> { #[cfg(test)]
Some("[feedback text]") mod tests {
use super::*;
use crate::acp::model_state::ModelState;
static DEFAULT_BUNDLE_STATE: crate::app::bundle::BundleState =
crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn make_ctx<'a>(models: &'a ModelState) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
} }
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult { #[test]
let trimmed = args.trim(); fn feedback_opens_github_issues() {
if trimmed.is_empty() { let models = ModelState::default();
CommandResult::Action(Action::EnterFeedbackMode) let mut ctx = make_ctx(&models);
} else { match FeedbackCommand.run(&mut ctx, "") {
CommandResult::Action(Action::SendFeedback(trimmed.to_string())) CommandResult::Action(Action::OpenUrl(url)) => {
assert_eq!(url, FEEDBACK_ISSUES_URL);
}
other => panic!("expected OpenUrl, got {other:?}"),
} }
} }
#[test]
fn feedback_ignores_stray_args() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
assert!(matches!(
FeedbackCommand.run(&mut ctx, "some typed text"),
CommandResult::Action(Action::OpenUrl(_))
));
}
#[test]
fn feedback_metadata() {
let cmd = FeedbackCommand;
assert_eq!(cmd.name(), "feedback");
assert!(!cmd.takes_args());
}
} }
@@ -41,7 +41,6 @@ pub mod plan;
pub mod plugin; pub mod plugin;
pub mod queue; pub mod queue;
pub mod recap; pub mod recap;
pub mod release_notes;
pub mod remember; pub mod remember;
pub mod rename; pub mod rename;
pub mod resume; pub mod resume;
@@ -121,7 +120,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(usage::UsageCommand), Arc::new(usage::UsageCommand),
Arc::new(queue::QueueCommand), Arc::new(queue::QueueCommand),
Arc::new(tasks::TasksCommand), Arc::new(tasks::TasksCommand),
Arc::new(release_notes::ReleaseNotesCommand),
Arc::new(config_agents::ConfigAgentsCommand), Arc::new(config_agents::ConfigAgentsCommand),
Arc::new(personas::PersonasCommand), Arc::new(personas::PersonasCommand),
Arc::new(gboom::GboomCommand), Arc::new(gboom::GboomCommand),
@@ -1,60 +0,0 @@
//! `/release-notes` -- view release notes for the current version.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Show release notes for the current pager version.
pub struct ReleaseNotesCommand;
impl SlashCommand for ReleaseNotesCommand {
fn name(&self) -> &str {
"release-notes"
}
fn aliases(&self) -> &[&str] {
&["changelog"]
}
fn description(&self) -> &str {
"View release notes for the current version"
}
fn usage(&self) -> &str {
"/release-notes"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let changelog = kigi_shell::util::changelog::ChangelogManager::new().fetch();
match changelog.markdown {
Some(content) => CommandResult::Action(Action::ShowReleaseNotes {
title: "Release Notes".to_string(),
content: content.trim().to_string(),
}),
None => CommandResult::Error("No release notes available (offline).".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_notes_metadata() {
let cmd = ReleaseNotesCommand;
assert_eq!(cmd.name(), "release-notes");
assert_eq!(cmd.aliases(), &["changelog"]);
assert!(!cmd.takes_args());
}
#[test]
fn release_notes_returns_action_or_error() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ReleaseNotesCommand.run(&mut ctx, "");
assert!(
matches!(result, CommandResult::Action(_) | CommandResult::Error(_)),
"expected Action or Error, got {result:?}"
);
}
}
@@ -282,6 +282,75 @@ pub fn goal_status_line(
]) ])
} }
// ---------------------------------------------------------------------------
// Graph status chip
// ---------------------------------------------------------------------------
/// Build the compact `/graph` status chip: node progress, the current
/// node, and spend. Same chip idiom as [`goal_status_line`] — dim
/// brackets, paused chips invert onto `theme.warning`, active chips
/// animate.
pub fn graph_status_line(
graph: &crate::app::agent::GraphDisplayState,
theme: &Theme,
tick: usize,
) -> Line<'static> {
let dim_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
let label_style = if graph.status.is_paused() {
Style::default().fg(theme.bg_base).bg(theme.warning)
} else {
Style::default().fg(theme.accent_plan).bg(theme.bg_base)
};
let is_active = matches!(graph.status, GoalDisplayStatus::Active);
let mut progress = format!("{}/{}", graph.achieved_nodes, graph.total_nodes);
if graph.failed_nodes > 0 {
progress.push_str(&format!(" ({} failed)", graph.failed_nodes));
}
// Planner titles are uncapped; clamp so one long title can't push
// the whole status bar off-screen.
let clamped_title = graph.current_node_title.as_deref().map(|t| {
if t.chars().count() > 40 {
let head: String = t.chars().take(39).collect();
format!("{head}")
} else {
t.to_owned()
}
});
let label = match (&graph.status, clamped_title.as_deref()) {
(GoalDisplayStatus::Active, Some(title)) => format!("{progress} · {title}"),
(GoalDisplayStatus::Active, None) if graph.running_nodes > 1 => {
format!("{progress} · {} nodes in flight", graph.running_nodes)
}
(GoalDisplayStatus::Complete, _) => format!("{progress} · complete"),
(GoalDisplayStatus::BudgetLimited, _) => format!("{progress} · budget limit"),
(status, _) if status.is_paused() => format!("{progress} · paused"),
_ => progress,
};
let graph_text = if is_active {
let frames = crate::glyphs::dot_spinner_frames();
let frame = frames[(tick / 4) % frames.len()];
format!("{frame} Graph: {label}")
} else {
format!("Graph: {label}")
};
let tokens_str = format_tokens_compact(graph.tokens_spent.max(0));
let tokens_display = match graph.token_budget {
Some(budget) if budget > 0 => {
format!("{}/{} tokens", tokens_str, format_tokens_compact(budget))
}
_ => format!("{tokens_str} tokens"),
};
Line::from(vec![
Span::styled("[", dim_style),
Span::styled(graph_text, label_style),
Span::styled("]", dim_style),
Span::styled(format!(" {tokens_display}"), dim_style),
])
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MCP connecting indicator // MCP connecting indicator
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -174,7 +174,7 @@ pub struct PromptStyle {
pub prefix_override: Option<(&'static str, ratatui::style::Color)>, pub prefix_override: Option<(&'static str, ratatui::style::Color)>,
/// Override the placeholder text shown when the textarea is empty. /// Override the placeholder text shown when the textarea is empty.
/// When `Some(text)`, uses this instead of the default `"Build anything"`. /// When `Some(text)`, uses this instead of the default `"Build anything"`.
/// Used for feedback mode (`"Type your feedback..."`). /// Used for remember mode (`"Save a memory note..."`).
pub placeholder_override: Option<&'static str>, pub placeholder_override: Option<&'static str>,
/// Compact mode (currently unused for info_block sizing). /// Compact mode (currently unused for info_block sizing).
pub compact: bool, pub compact: bool,
@@ -351,13 +351,13 @@ pub fn render_turn_status(
// ── Build components ── // ── Build components ──
// While a tool is blocked on a permission prompt or `ask_user_question`, // While a tool is blocked on a permission prompt or `ask_user_question`,
// swap the running braille spinner for a pulsing `◆`. Same animation // swap the spinning moon for a pulsing `◆`. Same animation shape the
// shape the drain-blocked and plan-approval indicators already use, // drain-blocked and plan-approval indicators already use, so every
// so every "your turn" status reads with one consistent visual cue. // "your turn" status reads with one consistent visual cue.
let spinner_str = if is_pending_user_input { let spinner_str = if is_pending_user_input {
format!("{} ", crate::glyphs::diamond_filled()) format!("{} ", crate::glyphs::diamond_filled())
} else { } else {
let frames = crate::glyphs::braille_spinner_frames(); let frames = crate::glyphs::moon_spinner_frames();
let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len(); let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len();
format!("{} ", frames[frame_idx]) format!("{} ", frames[frame_idx])
}; };
@@ -1,8 +1,8 @@
//! Hero box component — side-by-side logo + menu inside a bordered box. //! Hero box component — side-by-side logo + menu inside a bordered box.
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect}; use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::Style;
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::{Block, BorderType, Borders, Widget}; use ratatui::widgets::{Block, BorderType, Borders, Widget};
@@ -27,18 +27,11 @@ const HERO_SUBTITLE: &str = "Thanks for trying Kigi, give feedback with /feedbac
use super::{PROMPT_HEIGHT, VERSION_GAP}; use super::{PROMPT_HEIGHT, VERSION_GAP};
/// Rows the "thanks" subtitle occupies. Hidden when the in-box info slot /// Height of the hero box's right column: version + subtitle + the gap
/// (changelog) is shown, to keep the box compact. /// before the menu + the menu itself.
fn subtitle_rows(info_height: u16) -> u16 { fn right_col_height(menu_height: u16) -> u16 {
if info_height > 0 { 0 } else { 1 } // version(1) + subtitle(1) + gap-before-menu(1) + menu
} 3 + menu_height
/// Height of the hero box's right column: version + optional subtitle +
/// optional info block + the gap before the menu + the menu itself.
fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
let info_gap = if info_height > 0 { 1u16 } else { 0 };
// version(1) + subtitle + [info_gap + info] + gap-before-menu(1) + menu
1 + subtitle_rows(info_height) + info_gap + info_height + 1 + menu_height
} }
/// Minimum content-area height the hero box needs to render without truncating: /// Minimum content-area height the hero box needs to render without truncating:
@@ -46,13 +39,8 @@ fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
/// below (tip + prompt + version). The box always shows the full-height logo, /// below (tip + prompt + version). The box always shows the full-height logo,
/// so a terminal shorter than this falls back to the stacked layout instead of /// so a terminal shorter than this falls back to the stacked layout instead of
/// overflowing. /// overflowing.
pub(super) fn min_content_height( pub(super) fn min_content_height(error_height: u16, menu_height: u16, tip_height: u16) -> u16 {
error_height: u16, let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height));
menu_height: u16,
tip_height: u16,
info_height: u16,
) -> u16 {
let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height, info_height));
let hero_box_height = 2 + V_PAD * 2 + inner; let hero_box_height = 2 + V_PAD * 2 + inner;
let gap_after_error = if error_height > 0 { 1u16 } else { 0 }; let gap_after_error = if error_height > 0 { 1u16 } else { 0 };
gap_after_error + error_height + hero_box_height + 1 + WelcomeLayout::fixed_below(tip_height) gap_after_error + error_height + hero_box_height + 1 + WelcomeLayout::fixed_below(tip_height)
@@ -70,33 +58,26 @@ fn left_col_width() -> u16 {
} }
/// Compute the hero box layout: bordered box with logo left, version + menu right. /// Compute the hero box layout: bordered box with logo left, version + menu right.
///
/// Sizes the in-box info slot here (the fixed `changelog_height`) so the
/// renderer just draws into `hero_info`.
pub(super) fn compute_hero_box( pub(super) fn compute_hero_box(
content_area: Rect, content_area: Rect,
error_height: u16, error_height: u16,
menu_height: u16, menu_height: u16,
tip_height: u16, tip_height: u16,
changelog_height: u16,
) -> WelcomeLayout { ) -> WelcomeLayout {
let zero = Rect::default(); let zero = Rect::default();
let tip_gap = if tip_height > 0 { 1u16 } else { 0 }; let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
let fixed_below = WelcomeLayout::fixed_below(tip_height); let fixed_below = WelcomeLayout::fixed_below(tip_height);
// Column widths are height-independent, so derive them once and reuse for // Column widths are height-independent, so derive them once and reuse for
// both the measurement and the rects: `hero_info.width == info_slot_width`, // both the measurement and the rects.
// i.e. measured == drawn.
let box_width = content_area.width.saturating_sub(6).min(120); let box_width = content_area.width.saturating_sub(6).min(120);
let inner_width = box_width.saturating_sub(2); let inner_width = box_width.saturating_sub(2);
let left_col_width = left_col_width(); let left_col_width = left_col_width();
let right_width = inner_width.saturating_sub(left_col_width); let right_width = inner_width.saturating_sub(left_col_width);
let info_slot_width = right_width.saturating_sub(H_INSET); let menu_slot_width = right_width.saturating_sub(H_INSET);
let info_height = changelog_height;
let logo_rows = super::logo::full_logo_line_count(); let logo_rows = super::logo::full_logo_line_count();
let info_gap = if info_height > 0 { 1u16 } else { 0 }; let inner_height = logo_rows.max(right_col_height(menu_height));
let inner_height = logo_rows.max(right_col_height(menu_height, info_height));
let hero_box_height = 2 + V_PAD * 2 + inner_height; let hero_box_height = 2 + V_PAD * 2 + inner_height;
let gap_after_error = if error_height > 0 { 1 } else { 0 }; let gap_after_error = if error_height > 0 { 1 } else { 0 };
@@ -105,7 +86,7 @@ pub(super) fn compute_hero_box(
// Top padding for vertical centering (use the default menu height so the // Top padding for vertical centering (use the default menu height so the
// logo position stays constant regardless of picker/focus state). // logo position stays constant regardless of picker/focus state).
let default_menu_height = 4u16; let default_menu_height = 4u16;
let default_inner = logo_rows.max(right_col_height(default_menu_height, info_height)); let default_inner = logo_rows.max(right_col_height(default_menu_height));
let default_hero = 2 + V_PAD * 2 + default_inner; let default_hero = 2 + V_PAD * 2 + default_inner;
let remaining = content_area.height.saturating_sub(fixed_above); let remaining = content_area.height.saturating_sub(fixed_above);
let top_pad = remaining let top_pad = remaining
@@ -190,39 +171,22 @@ pub(super) fn compute_hero_box(
height: 1, height: 1,
}; };
// Subtitle line below version — hidden when the info slot is shown. // Subtitle line below version.
let hero_subtitle = if subtitle_rows(info_height) > 0 { let hero_subtitle = Rect {
Rect {
x: right_x, x: right_x,
y: inner.y + 1, y: inner.y + 1,
width: right_width, width: right_width,
height: 1, height: 1,
}
} else {
zero
}; };
// Info block (changelog) below version + optional subtitle. // version + subtitle + gap-before-menu
let info_y = inner.y + 1 + subtitle_rows(info_height) + info_gap; let right_header_rows = 3;
let hero_info = if info_height > 0 {
Rect {
x: right_x,
y: info_y,
width: info_slot_width,
height: info_height,
}
} else {
zero
};
// version + subtitle + info_gap + info + gap-before-menu
let right_header_rows = 1 + subtitle_rows(info_height) + info_gap + info_height + 1;
// Menu below the header rows, left-aligned in right column. // Menu below the header rows, left-aligned in right column.
let hero_menu = Rect { let hero_menu = Rect {
x: right_x, x: right_x,
y: inner.y + right_header_rows, y: inner.y + right_header_rows,
width: info_slot_width, width: menu_slot_width,
height: menu_height.min(inner.height.saturating_sub(right_header_rows)), height: menu_height.min(inner.height.saturating_sub(right_header_rows)),
}; };
@@ -230,7 +194,6 @@ pub(super) fn compute_hero_box(
logo: zero, logo: zero,
error, error,
menu: zero, menu: zero,
changelog: zero,
tip, tip,
prompt, prompt,
version: version_slot, version: version_slot,
@@ -238,26 +201,12 @@ pub(super) fn compute_hero_box(
hero_logo, hero_logo,
hero_version, hero_version,
hero_subtitle, hero_subtitle,
hero_info,
hero_menu, hero_menu,
} }
} }
/// Changelog content shown in the hero box info slot.
pub(super) struct ChangelogDisplay<'a> {
pub(super) bullets: &'a [String],
pub(super) has_full_notes: bool,
}
/// Hit-test rects produced by [`render_hero_box`].
pub(super) struct HeroBoxRects {
/// Hit-test rect per menu item row (for click/hover).
pub(super) menu_rects: Vec<Rect>,
/// Clickable changelog info block, if drawn.
pub(super) changelog_cta_rect: Option<Rect>,
}
/// Render the bordered hero box with logo left, version + subtitle + menu right. /// Render the bordered hero box with logo left, version + subtitle + menu right.
/// Returns the hit-test rect per menu item row (for click/hover).
pub(super) fn render_hero_box( pub(super) fn render_hero_box(
layout: &WelcomeLayout, layout: &WelcomeLayout,
buf: &mut Buffer, buf: &mut Buffer,
@@ -265,8 +214,7 @@ pub(super) fn render_hero_box(
menu_items: &[(&str, &str)], menu_items: &[(&str, &str)],
selected: Option<usize>, selected: Option<usize>,
mouse_pos: Option<(u16, u16)>, mouse_pos: Option<(u16, u16)>,
changelog: ChangelogDisplay<'_>, ) -> Vec<Rect> {
) -> HeroBoxRects {
// Dim the box border toward the background for a softer, dimmer gray. // Dim the box border toward the background for a softer, dimmer gray.
let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45) let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
.unwrap_or(theme.gray_dim); .unwrap_or(theme.gray_dim);
@@ -289,7 +237,6 @@ pub(super) fn render_hero_box(
); );
// Subtitle line below the version. // Subtitle line below the version.
if layout.hero_subtitle.height > 0 {
let subtitle_style = Style::default().fg(theme.gray); let subtitle_style = Style::default().fg(theme.gray);
buf.set_span( buf.set_span(
layout.hero_subtitle.x, layout.hero_subtitle.x,
@@ -297,22 +244,8 @@ pub(super) fn render_hero_box(
&Span::styled(HERO_SUBTITLE, subtitle_style), &Span::styled(HERO_SUBTITLE, subtitle_style),
layout.hero_subtitle.width, layout.hero_subtitle.width,
); );
}
// In-box info slot: the changelog, always in this same position. super::menu::render_menu(
let mut changelog_cta_rect = None;
if layout.hero_info.height > 0 && !changelog.bullets.is_empty() {
changelog_cta_rect = render_hero_changelog(
buf,
theme,
layout.hero_info,
changelog.bullets,
changelog.has_full_notes,
mouse_pos,
);
}
let menu_rects = super::menu::render_menu(
layout.hero_menu, layout.hero_menu,
buf, buf,
theme, theme,
@@ -320,58 +253,5 @@ pub(super) fn render_hero_box(
selected, selected,
mouse_pos, mouse_pos,
layout.hero_menu.width, layout.hero_menu.width,
); )
HeroBoxRects {
menu_rects,
changelog_cta_rect,
}
}
/// Render the changelog block (header + bullets) in the info slot. When
/// `clickable` (full notes exist), the whole block opens the notes on click and
/// brightens while hovered; returns that clickable rect.
fn render_hero_changelog(
buf: &mut Buffer,
theme: &Theme,
area: Rect,
bullets: &[String],
clickable: bool,
mouse_pos: Option<(u16, u16)>,
) -> Option<Rect> {
if area.width == 0 || area.height == 0 {
return None;
}
let hovered =
clickable && mouse_pos.is_some_and(|(mx, my)| area.contains(Position::new(mx, my)));
let header_style = super::hover_style(
theme,
hovered,
Style::default()
.fg(theme.gray_bright)
.add_modifier(Modifier::DIM),
);
let title = "Changelog";
buf.set_span(
area.x,
area.y,
&Span::styled(title, header_style),
area.width,
);
// Bullets start 2 rows down (header + blank), matching the height budget.
let bullet_style = super::hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
let max_text_width = area.width.saturating_sub(4) as usize; // " • " prefix + pad
for (i, bullet) in bullets.iter().enumerate() {
let row = area.y + 2 + i as u16;
if row >= area.y + area.height {
break;
}
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
let text = format!(" \u{2022} {truncated}");
buf.set_span(area.x, row, &Span::styled(text, bullet_style), area.width);
}
clickable.then_some(area)
} }
@@ -7,7 +7,8 @@
//! phase model: the terminator is the ellipse `x = cos(2πp)·√(1y²)`, with //! phase model: the terminator is the ellipse `x = cos(2πp)·√(1y²)`, with
//! sunlight arriving from the right while waxing and from the left while //! sunlight arriving from the right while waxing and from the left while
//! waning. The dark limb keeps a faint outline ring so the silhouette never //! waning. The dark limb keeps a faint outline ring so the silhouette never
//! disappears at new moon. //! disappears at new moon. A fixed map of lunar maria textures the disc:
//! dark blotches on the sunlit side, faint gray patches on the dark limb.
//! //!
//! Hidden entirely on legacy Windows consoles: the U+2800 braille block is //! Hidden entirely on legacy Windows consoles: the U+2800 braille block is
//! not covered by the ConHost raster fonts and would render as tofu. //! not covered by the ConHost raster fonts and would render as tofu.
@@ -52,6 +53,30 @@ const PULSE_SECS: f32 = 5.0;
/// Squared inner radius (normalized) of the dark-limb outline ring. /// Squared inner radius (normalized) of the dark-limb outline ring.
const RING_INNER_SQ: f32 = 0.82; const RING_INNER_SQ: f32 = 0.82;
/// Lunar maria as `(cx, cy, radius²)` in normalized disc coordinates
/// (x right, y down), loosely after the near side's real maria. On the
/// sunlit disc a mare dot is drawn in the resting gray (a dark blotch);
/// on the dark limb it is drawn in the same gray, which reads as a faint
/// light patch against the empty limb.
const MARIA: &[(f32, f32, f32)] = &[
(-0.40, -0.42, 0.018), // Imbrium
(0.12, -0.50, 0.008), // Serenitatis
(0.40, -0.22, 0.012), // Tranquillitatis
(0.55, 0.20, 0.005), // Fecunditatis
(0.62, -0.42, 0.004), // Crisium
(-0.58, 0.05, 0.010), // Procellarum
(-0.28, 0.40, 0.006), // Nubium
(0.08, 0.12, 0.004), // Vaporum
];
fn in_mare(dx: f32, dy: f32) -> bool {
MARIA.iter().any(|&(mx, my, r_sq)| {
let ex = dx - mx;
let ey = dy - my;
ex * ex + ey * ey <= r_sq
})
}
/// One logo size tier, in braille cells. /// One logo size tier, in braille cells.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct MoonSize { struct MoonSize {
@@ -152,8 +177,8 @@ fn moon_cells(size: MoonSize, p: f32) -> Vec<Vec<Option<MoonCell>>> {
.map(|cell_row| { .map(|cell_row| {
(0..size.cols as i32) (0..size.cols as i32)
.map(|cell_col| { .map(|cell_col| {
let mut mask = 0u32; let mut lit_mask = 0u32;
let mut lit = false; let mut dark_mask = 0u32;
for (dot_col, col_bits) in DOT_BITS.iter().enumerate() { for (dot_col, col_bits) in DOT_BITS.iter().enumerate() {
for (dot_row, bit) in col_bits.iter().enumerate() { for (dot_row, bit) in col_bits.iter().enumerate() {
let x = cell_col * 2 + dot_col as i32; let x = cell_col * 2 + dot_col as i32;
@@ -164,15 +189,28 @@ fn moon_cells(size: MoonSize, p: f32) -> Vec<Vec<Option<MoonCell>>> {
if d_sq > 1.0 { if d_sq > 1.0 {
continue; continue;
} }
let mare = in_mare(dx, dy);
if dot_lit(dx, dy, p) { if dot_lit(dx, dy, p) {
mask |= bit; if mare {
lit = true; // Dark blotch on the sunlit disc.
} else if d_sq >= RING_INNER_SQ { dark_mask |= bit;
// Dark limb: keep the outline ring visible. } else {
mask |= bit; lit_mask |= bit;
}
} else if mare || d_sq >= RING_INNER_SQ {
// Dark limb: outline ring plus faint maria.
dark_mask |= bit;
} }
} }
} }
// A braille cell holds a single color, so a cell with any
// sunlit dots renders only those (mare dots in it stay
// background-dark); otherwise its dark dots render gray.
let (mask, lit) = if lit_mask != 0 {
(lit_mask, true)
} else {
(dark_mask, false)
};
(mask != 0).then(|| MoonCell { (mask != 0).then(|| MoonCell {
ch: char::from_u32(0x2800 + mask).expect("braille block"), ch: char::from_u32(0x2800 + mask).expect("braille block"),
lit, lit,
@@ -407,6 +445,32 @@ mod tests {
); );
} }
#[test]
fn maria_texture_the_disc_in_both_extremes() {
// Full moon: mare dots stay dark, so the drawn glyphs must cover
// fewer dots than the geometric disc (lit_dots ignores maria).
let full = moon_cells(FULL, 0.5);
let drawn_dots: u32 = full
.iter()
.flatten()
.flatten()
.map(|c| (c.ch as u32 - 0x2800).count_ones())
.sum();
assert!(
(drawn_dots as usize) < lit_dots(0.5),
"full moon must keep dark maria holes"
);
// New moon: maria show as drawn (gray) cells well inside the outline
// ring — Procellarum sits around cell (5, 4) on the full-size grid.
let new = moon_cells(FULL, 0.0);
assert!(
new[5][4].is_some(),
"new moon must show maria inside the ring"
);
assert!(in_mare(-0.58, 0.05), "Procellarum anchors the maria map");
assert!(!in_mare(0.0, 0.85), "south pole stays mare-free");
}
#[test] #[test]
fn moon_raster_is_round_and_fills_the_grid() { fn moon_raster_is_round_and_fills_the_grid() {
// The disc must span (nearly) the whole cell grid in both axes at // The disc must span (nearly) the whole cell grid in both axes at
+13 -374
View File
@@ -7,7 +7,7 @@
//! - Bottom margin //! - Bottom margin
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Position, Rect}; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
@@ -56,22 +56,12 @@ fn quit_hint_spans(theme: &Theme) -> Vec<Span<'static>> {
] ]
} }
/// Style for a clickable welcome block: bright primary while `hovered`, else
/// `base`. Shared by the changelog renderer.
pub(super) fn hover_style(theme: &Theme, hovered: bool, base: Style) -> Style {
if hovered {
Style::default().fg(theme.text_primary)
} else {
base
}
}
/// Horizontal margin (left and right) in normal mode. /// Horizontal margin (left and right) in normal mode.
const H_MARGIN: u16 = 2; const H_MARGIN: u16 = 2;
/// Horizontal margin in compact mode. /// Horizontal margin in compact mode.
const H_MARGIN_COMPACT: u16 = 1; const H_MARGIN_COMPACT: u16 = 1;
/// Minimum width for menu + changelog sections so they don't resize when the import row toggles. /// Minimum width for the menu section so it doesn't resize when the import row toggles.
/// Derivation: "[ " (2) + import-claude label (22) + gap (4) + "ctrl+i [x]" (11) + " ]" (2) = 41. /// Derivation: "[ " (2) + import-claude label (22) + gap (4) + "ctrl+i [x]" (11) + " ]" (2) = 41.
/// Bumped to 51 for comfortable breathing room. /// Bumped to 51 for comfortable breathing room.
const MENU_MIN_WIDTH: u16 = 51; const MENU_MIN_WIDTH: u16 = 51;
@@ -103,13 +93,6 @@ pub struct WelcomeRenderResult {
pub auth_url_rect: Option<Rect>, pub auth_url_rect: Option<Rect>,
/// Hit-test rect for the "show full URL" fallback link. /// Hit-test rect for the "show full URL" fallback link.
pub auth_fallback_rect: Option<Rect>, pub auth_fallback_rect: Option<Rect>,
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
/// Whether a "Changelog" menu action was rendered (above Quit), so the
/// input handler can map the extra menu row to the release-notes action
/// once markdown is available.
pub changelog_action_present: bool,
/// Hit-test rect for the clickable changelog info block (opens release notes).
pub changelog_cta_rect: Option<Rect>,
} }
use hero_box::HERO_BOX_MIN_WIDTH; use hero_box::HERO_BOX_MIN_WIDTH;
@@ -124,9 +107,6 @@ pub(super) struct WelcomeLayout {
pub(super) logo: Rect, pub(super) logo: Rect,
pub(super) error: Rect, pub(super) error: Rect,
pub(super) menu: Rect, pub(super) menu: Rect,
/// Stacked info slot below the menu (narrow layout only) — shows the
/// changelog. Zero in the hero box layout, which uses `hero_info` instead.
pub(super) changelog: Rect,
pub(super) tip: Rect, pub(super) tip: Rect,
pub(super) prompt: Rect, pub(super) prompt: Rect,
pub(super) version: Rect, pub(super) version: Rect,
@@ -135,8 +115,6 @@ pub(super) struct WelcomeLayout {
pub(super) hero_logo: Rect, pub(super) hero_logo: Rect,
pub(super) hero_version: Rect, pub(super) hero_version: Rect,
pub(super) hero_subtitle: Rect, pub(super) hero_subtitle: Rect,
/// In-box info slot — shows the changelog.
pub(super) hero_info: Rect,
pub(super) hero_menu: Rect, pub(super) hero_menu: Rect,
} }
@@ -151,9 +129,7 @@ struct WelcomeLayoutInput {
error_height: u16, error_height: u16,
menu_height: u16, menu_height: u16,
tip_height: u16, tip_height: u16,
/// Desired changelog height (collapsed to 0 if the terminal is too short). /// Vertical compaction (session picker visible): skip the logo.
changelog_height: u16,
/// Vertical compaction (session picker visible): skip the logo + info slot.
compact: bool, compact: bool,
/// Horizontal-inset compaction (appearance setting) for the stacked slot. /// Horizontal-inset compaction (appearance setting) for the stacked slot.
prompt_compact: bool, prompt_compact: bool,
@@ -170,22 +146,6 @@ impl WelcomeLayout {
tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1 tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1
} }
pub(super) fn effective_changelog(
content_height: u16,
fixed_above: u16,
content_slot: u16,
fixed_below: u16,
requested: u16,
) -> (u16, u16) {
let gap = if requested > 0 { 1u16 } else { 0 };
let min_without = fixed_above + content_slot + 1 + fixed_below;
if requested > 0 && content_height >= min_without + gap + requested {
(requested, 1)
} else {
(0, 0)
}
}
/// Compute the welcome screen layout, allowing the wide hero-box variant. /// Compute the welcome screen layout, allowing the wide hero-box variant.
fn compute(input: WelcomeLayoutInput) -> Self { fn compute(input: WelcomeLayoutInput) -> Self {
Self::compute_inner(input, true) Self::compute_inner(input, true)
@@ -203,17 +163,14 @@ impl WelcomeLayout {
/// Compute the welcome screen layout. /// Compute the welcome screen layout.
/// ///
/// Picks hero vs stacked, then measures the info slot (the changelog) at /// Picks hero vs stacked. `allow_hero_box` gates the wide variant;
/// that layout's slot width before placing rects — width is /// stacked-only callers pass `false`.
/// content-size-only, so it's a clean two-phase computation. `allow_hero_box`
/// gates the wide variant; stacked-only callers pass `false`.
fn compute_inner(input: WelcomeLayoutInput, allow_hero_box: bool) -> Self { fn compute_inner(input: WelcomeLayoutInput, allow_hero_box: bool) -> Self {
let WelcomeLayoutInput { let WelcomeLayoutInput {
content_area, content_area,
error_height, error_height,
menu_height, menu_height,
tip_height, tip_height,
changelog_height,
compact, compact,
prompt_compact, prompt_compact,
} = input; } = input;
@@ -224,26 +181,12 @@ impl WelcomeLayout {
&& content_area.width >= HERO_BOX_MIN_WIDTH && content_area.width >= HERO_BOX_MIN_WIDTH
&& menu_height > 0 && menu_height > 0
&& content_area.height && content_area.height
>= hero_box::min_content_height( >= hero_box::min_content_height(error_height, menu_height, tip_height);
error_height,
menu_height,
tip_height,
changelog_height,
);
if use_hero_box { if use_hero_box {
return hero_box::compute_hero_box( return hero_box::compute_hero_box(content_area, error_height, menu_height, tip_height);
content_area,
error_height,
menu_height,
tip_height,
changelog_height,
);
} }
// Stacked info slot: the changelog.
let info_height = changelog_height;
// Stacked layout: skip the logo in compact mode (the session picker // Stacked layout: skip the logo in compact mode (the session picker
// needs the space); otherwise pick small/full/none by height. // needs the space); otherwise pick small/full/none by height.
let logo_rows = if compact { let logo_rows = if compact {
@@ -256,19 +199,6 @@ impl WelcomeLayout {
let tip_gap = if tip_height > 0 { 1u16 } else { 0 }; let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
let fixed_below = Self::fixed_below(tip_height); let fixed_below = Self::fixed_below(tip_height);
let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo
// The stacked info slot below the menu holds the changelog.
let (eff_changelog_height, _) = if !compact {
Self::effective_changelog(
content_area.height,
fixed_above,
menu_height,
fixed_below,
info_height,
)
} else {
(0, 0)
};
let eff_changelog_gap = if eff_changelog_height > 0 { 1u16 } else { 0 };
// Compute top_pad using the *default* menu height (4 items = 7 rows) so // Compute top_pad using the *default* menu height (4 items = 7 rows) so
// the logo position stays constant regardless of picker/focus state. // the logo position stays constant regardless of picker/focus state.
let top_pad = if compact { let top_pad = if compact {
@@ -278,36 +208,18 @@ impl WelcomeLayout {
let remaining = content_area.height.saturating_sub(fixed_above); let remaining = content_area.height.saturating_sub(fixed_above);
remaining remaining
.saturating_sub(default_menu_height) .saturating_sub(default_menu_height)
.saturating_sub(eff_changelog_gap + eff_changelog_height)
.saturating_sub(fixed_below) .saturating_sub(fixed_below)
/ 3 / 3
}; };
let logo_gap = 1u16; let logo_gap = 1u16;
let flex_gap = 1u16; let flex_gap = 1u16;
let [ let [_, logo, _, _, error, menu, _, tip, _, prompt, _, version] = Layout::vertical([
_,
logo,
_,
_,
error,
menu,
_,
changelog,
_,
tip,
_,
prompt,
_,
version,
] = Layout::vertical([
Constraint::Length(top_pad), Constraint::Length(top_pad),
Constraint::Length(logo_rows), Constraint::Length(logo_rows),
Constraint::Length(logo_gap), // gap after logo Constraint::Length(logo_gap), // gap after logo
Constraint::Length(gap_after_logo), Constraint::Length(gap_after_logo),
Constraint::Length(error_height), Constraint::Length(error_height),
Constraint::Length(menu_height), Constraint::Length(menu_height),
Constraint::Length(eff_changelog_gap),
Constraint::Length(eff_changelog_height),
Constraint::Min(flex_gap), Constraint::Min(flex_gap),
Constraint::Length(tip_height), Constraint::Length(tip_height),
Constraint::Length(tip_gap), Constraint::Length(tip_gap),
@@ -320,7 +232,6 @@ impl WelcomeLayout {
logo, logo,
error, error,
menu, menu,
changelog,
tip, tip,
prompt, prompt,
version, version,
@@ -328,7 +239,6 @@ impl WelcomeLayout {
hero_logo: zero, hero_logo: zero,
hero_version: zero, hero_version: zero,
hero_subtitle: zero, hero_subtitle: zero,
hero_info: zero,
hero_menu: zero, hero_menu: zero,
} }
} }
@@ -566,10 +476,6 @@ pub struct WelcomeRenderParams<'a> {
/// Live working directory (tracks `Effect::SetWorkingDir`), used to pin /// Live working directory (tracks `Effect::SetWorkingDir`), used to pin
/// the current repo's session group to the top of the picker. /// the current repo's session group to the top of the picker.
pub cwd: &'a std::path::Path, pub cwd: &'a std::path::Path,
/// Cached changelog bullets for the welcome screen (up to 3).
pub changelog_bullets: &'a [String],
/// Whether full release notes markdown is available (controls the CTA hint).
pub changelog_has_full_notes: bool,
} }
/// Render the welcome screen. /// Render the welcome screen.
@@ -642,8 +548,6 @@ pub fn render_welcome(
import_banner_rect: None, import_banner_rect: None,
auth_url_rect: None, auth_url_rect: None,
auth_fallback_rect: None, auth_fallback_rect: None,
changelog_action_present: false,
changelog_cta_rect: None,
} }
} }
AuthState::Authenticating { auth_url, mode, .. } => { AuthState::Authenticating { auth_url, mode, .. } => {
@@ -668,8 +572,6 @@ pub fn render_welcome(
import_banner_rect: None, import_banner_rect: None,
auth_url_rect: url_rect, auth_url_rect: url_rect,
auth_fallback_rect: fallback_rect, auth_fallback_rect: fallback_rect,
changelog_action_present: false,
changelog_cta_rect: None,
} }
} }
// Folder-trust question: shown after auth, before any session is // Folder-trust question: shown after auth, before any session is
@@ -1450,73 +1352,6 @@ fn inset_horizontal(rect: Rect, inset: u16) -> Rect {
} }
} }
/// Render the changelog section (header + bullets), centered to the menu width.
/// When `clickable` (full notes exist) the whole block opens the notes on click
/// and brightens while hovered; returns that clickable rect.
#[allow(clippy::too_many_arguments)]
fn render_changelog_section(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
bullets: &[String],
min_width_hint: u16,
content_height: u16,
clickable: bool,
mouse_pos: Option<(u16, u16)>,
) -> Option<Rect> {
let menu_width = logo::logo_visual_width(content_height)
.max(30)
.max(min_width_hint);
let [_, centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(menu_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(area);
if centered.width < 20 || centered.height == 0 {
return None;
}
let hovered =
clickable && mouse_pos.is_some_and(|(mx, my)| centered.contains(Position::new(mx, my)));
let header_style = hover_style(
theme,
hovered,
Style::default()
.fg(theme.gray_bright)
.add_modifier(Modifier::DIM),
);
let title = "Changelog";
buf.set_span(
centered.x,
centered.y,
&Span::styled(title, header_style),
centered.width,
);
let bullet_style = hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
let max_text_width = centered.width.saturating_sub(2) as usize; // "• " prefix = 2 cols
for (i, bullet) in bullets.iter().enumerate() {
let row = centered.y + 2 + i as u16;
if row >= centered.y + centered.height {
break;
}
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
let text = format!("\u{2022} {truncated}");
buf.set_span(
centered.x,
row,
&Span::styled(text, bullet_style),
centered.width,
);
}
clickable.then_some(centered)
}
/// Render the normal welcome screen (Done state -- already authenticated). /// Render the normal welcome screen (Done state -- already authenticated).
fn render_welcome_done( fn render_welcome_done(
content_area: Rect, content_area: Rect,
@@ -1535,8 +1370,6 @@ fn render_welcome_done(
let in_vscode_family = welcome_in_vscode_family(); let in_vscode_family = welcome_in_vscode_family();
// Heights that don't depend on the menu — computed first so the menu
// builder can probe the layout to decide whether to add a Changelog row.
// Startup-warning hint height (multi-line aware). // Startup-warning hint height (multi-line aware).
let hint_height = p.startup_warnings.first().map_or(0u16, |w| { let hint_height = p.startup_warnings.first().map_or(0u16, |w| {
let msg_lines = w.message.lines().count() as u16; let msg_lines = w.message.lines().count() as u16;
@@ -1558,14 +1391,6 @@ fn render_welcome_done(
} else { } else {
0 0
}; };
let changelog_height = if !show_picker && !p.changelog_bullets.is_empty() {
2 + p.changelog_bullets.len() as u16
} else {
0
};
// Changelog is reachable via this menu row (ctrl+l). Show from the first
// frame so the menu doesn't shift while the CDN fetch completes.
let show_changelog_action = !show_picker;
let owned_menu; let owned_menu;
let menu_items: &[(&str, &str)] = { let menu_items: &[(&str, &str)] = {
@@ -1577,7 +1402,7 @@ fn render_welcome_done(
); );
// Insert the import row at the top when there are pending `.claude/` // Insert the import row at the top when there are pending `.claude/`
// settings to import — it's the most actionable item right now. // settings to import — it's the most actionable item right now.
let mut items: Vec<(&str, &str)> = Vec::with_capacity(5); let mut items: Vec<(&str, &str)> = Vec::with_capacity(4);
if p.has_claude_import { if p.has_claude_import {
// The trailing "[x]" is a clickable dismiss affordance — the // The trailing "[x]" is a clickable dismiss affordance — the
// welcome screen mouse handler treats clicks on the rightmost // welcome screen mouse handler treats clicks on the rightmost
@@ -1588,10 +1413,6 @@ fn render_welcome_done(
} }
items.push((key_w, "New worktree")); items.push((key_w, "New worktree"));
items.push((key_s, "Resume session")); items.push((key_s, "Resume session"));
// "Changelog" above Quit; no shortcut — opened by click (row or block).
if show_changelog_action {
items.push(("", "Changelog"));
}
items.push((key_q, "Quit")); items.push((key_q, "Quit"));
owned_menu = items; owned_menu = items;
owned_menu.as_slice() owned_menu.as_slice()
@@ -1620,7 +1441,6 @@ fn render_welcome_done(
error_height: hint_height, error_height: hint_height,
menu_height: content_height, menu_height: content_height,
tip_height, tip_height,
changelog_height,
compact: welcome_compact, compact: welcome_compact,
prompt_compact: p.compact, prompt_compact: p.compact,
}); });
@@ -1628,9 +1448,6 @@ fn render_welcome_done(
// Render startup warning in the error area (same slot as auth errors). // Render startup warning in the error area (same slot as auth errors).
let import_banner_rect = render_startup_warnings(layout.error, buf, theme, p.startup_warnings); let import_banner_rect = render_startup_warnings(layout.error, buf, theme, p.startup_warnings);
// Hit-rects, set by whichever layout draws each block.
let mut changelog_cta_rect: Option<Rect> = None;
let (menu_rects, picker_close_button) = if show_picker { let (menu_rects, picker_close_button) = if show_picker {
// Use the full area since logo/menu are hidden and shortcuts // Use the full area since logo/menu are hidden and shortcuts
// are now rendered inside the picker content area. // are now rendered inside the picker content area.
@@ -1663,20 +1480,9 @@ fn render_welcome_done(
(vec![], Some(hit_areas)) (vec![], Some(hit_areas))
} else if layout.has_hero_box() { } else if layout.has_hero_box() {
// Wide layout: render bordered hero box with logo left, version + menu right. // Wide layout: render bordered hero box with logo left, version + menu right.
let rects = hero_box::render_hero_box( let menu_rects =
&layout, hero_box::render_hero_box(&layout, buf, theme, menu_items, p.selected, p.mouse_pos);
buf, (menu_rects, None)
theme,
menu_items,
p.selected,
p.mouse_pos,
hero_box::ChangelogDisplay {
bullets: p.changelog_bullets,
has_full_notes: p.changelog_has_full_notes,
},
);
changelog_cta_rect = rects.changelog_cta_rect;
(rects.menu_rects, None)
} else { } else {
// Narrow layout: stacked logo above, menu below. Inset the menu the // Narrow layout: stacked logo above, menu below. Inset the menu the
// same as the input bar (`prompt_inset`) so it keeps side spacing // same as the input bar (`prompt_inset`) so it keeps side spacing
@@ -1697,23 +1503,6 @@ fn render_welcome_done(
) )
}; };
// Stacked info slot below the menu (narrow layout): show the changelog,
// mirroring the hero box. Inset to match the input bar so it lines up with
// the menu above.
if layout.changelog.height > 0 {
let info_area = inset_horizontal(layout.changelog, prompt::prompt_inset(p.compact));
changelog_cta_rect = render_changelog_section(
info_area,
buf,
theme,
p.changelog_bullets,
MENU_MIN_WIDTH,
content_area.height,
p.changelog_has_full_notes,
p.mouse_pos,
);
}
// Skip the prompt input when picker is visible to save space; // Skip the prompt input when picker is visible to save space;
// shortcuts are rendered inside the picker content area. // shortcuts are rendered inside the picker content area.
let (cursor_pos, post_flush_escapes) = if show_picker { let (cursor_pos, post_flush_escapes) = if show_picker {
@@ -1840,8 +1629,6 @@ fn render_welcome_done(
import_banner_rect, import_banner_rect,
auth_url_rect: None, auth_url_rect: None,
auth_fallback_rect: None, auth_fallback_rect: None,
changelog_action_present: show_changelog_action,
changelog_cta_rect,
} }
} }
@@ -2278,8 +2065,6 @@ mod tests {
session_picker_source_filter: crate::views::session_picker::SourceFilter::All, session_picker_source_filter: crate::views::session_picker::SourceFilter::All,
chat_mode: false, chat_mode: false,
cwd: std::path::Path::new("/repo"), cwd: std::path::Path::new("/repo"),
changelog_bullets: &[],
changelog_has_full_notes: false,
} }
} }
@@ -2747,106 +2532,6 @@ mod tests {
assert_eq!(state.query, "e"); assert_eq!(state.query, "e");
} }
#[test]
fn changelog_hidden_on_short_terminal() {
let area = Rect::new(0, 0, 80, 15);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
}
#[test]
fn changelog_shown_on_tall_terminal() {
let area = Rect::new(0, 0, 80, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 5);
}
#[test]
fn changelog_hidden_when_compact() {
let area = Rect::new(0, 0, 80, 60);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
changelog_height: 5,
compact: true,
prompt_compact: true,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
}
#[test]
fn changelog_hidden_when_zero_requested() {
let area = Rect::new(0, 0, 80, 60);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
}
#[test]
fn changelog_boundary_exact_fit() {
// No logo at h < 22. fixed_above = 0 + 1 + 0 + 0 = 1.
// fixed_below = 0 (tip) + 0 (tip_gap) + 3 (prompt) + 1 (ver_gap) + 1 (ver) = 5.
// min_without_changelog = 1 + 4 (menu) + 1 (flex) + 5 = 11.
// changelog slot = 1 (gap) + 5 (height) = 6. Threshold = 11 + 6 = 17.
let just_fits = Rect::new(0, 0, 80, 17);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: just_fits,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 5);
let too_short = Rect::new(0, 0, 80, 16);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: too_short,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
}
#[test]
fn changelog_hidden_when_tip_steals_space() {
// Use narrow width to avoid hero box path, keeping stacked layout.
// With tip_height=2: fixed_below(2) = 8. min = 1 + 4 + 1 + 8 = 14.
// Threshold = 14 + 6 = 20. At h=19 the tip pushes changelog out.
let with_tip = Rect::new(0, 0, 60, 19);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: with_tip,
menu_height: 4,
tip_height: 2,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
// Same size without tip: threshold = 17 <= 19, changelog fits.
let without_tip = Rect::new(0, 0, 60, 19);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: without_tip,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 5);
}
#[test] #[test]
fn hero_box_active_on_wide_tall_terminal() { fn hero_box_active_on_wide_tall_terminal() {
// 90 cols, 50 rows: meets the minimum for the hero box. // 90 cols, 50 rows: meets the minimum for the hero box.
@@ -2866,6 +2551,7 @@ mod tests {
assert!(layout.hero_logo.height > 0); assert!(layout.hero_logo.height > 0);
assert!(layout.hero_menu.height > 0); assert!(layout.hero_menu.height > 0);
assert_eq!(layout.hero_version.height, 1); assert_eq!(layout.hero_version.height, 1);
assert_eq!(layout.hero_subtitle.height, 1);
} }
#[test] #[test]
@@ -3056,53 +2742,6 @@ mod tests {
assert_eq!(layout.hero_logo.y, layout.hero_box.y + 2); assert_eq!(layout.hero_logo.y, layout.hero_box.y + 2);
} }
#[test]
fn hero_box_with_changelog() {
// The changelog renders inside the box (info slot), not in a
// separate area below it.
let area = Rect::new(0, 0, 100, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 3,
changelog_height: 5,
..Default::default()
});
assert!(layout.has_hero_box());
assert_eq!(layout.changelog.height, 0);
assert_eq!(layout.hero_info.height, 5);
// The subtitle is hidden when the info slot is shown.
assert_eq!(layout.hero_subtitle.height, 0);
assert!(layout.hero_info.y > layout.hero_version.y);
}
#[test]
fn hero_box_keeps_one_bottom_pad_below_actions() {
// With a changelog the subtitle is hidden, but there's still exactly
// one padding row between the actions and the bottom border.
// (menu=4 + info=3 fills the inner, so the menu reaches the pad.)
let area = Rect::new(0, 0, 100, 50);
let no_info = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
..Default::default()
});
let with_info = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
changelog_height: 3,
..Default::default()
});
assert_eq!(no_info.hero_subtitle.height, 1);
assert_eq!(with_info.hero_subtitle.height, 0);
let menu_bottom = with_info.hero_menu.y + with_info.hero_menu.height;
let border_bottom = with_info.hero_box.y + with_info.hero_box.height - 1;
assert_eq!(
border_bottom - menu_bottom,
1,
"one pad row below the actions"
);
}
/// Flatten a rendered buffer into one string for substring assertions. /// Flatten a rendered buffer into one string for substring assertions.
fn buffer_text(buf: &Buffer) -> String { fn buffer_text(buf: &Buffer) -> String {
let area = *buf.area(); let area = *buf.area();
@@ -0,0 +1,40 @@
name: graph-slash-presession
description: >
With the graph feature flag on (`KIGI_GRAPH=1`, plus `KIGI_GOAL=1` — the
graph gate requires the goal harness), `/graph` must appear in the
slash-command menu on the welcome screen *before* the first user turn
creates a session. Typing `/graph` pre-session must surface the command
and its description in the dropdown.
terminal:
rows: 40
cols: 100
environment:
env:
- key: KIGI_GOAL
value: "1"
- key: KIGI_GRAPH
value: "1"
mock:
response: "unused — this scenario never submits a prompt."
steps:
- action: wait_for_text
text: Quit
timeout_ms: 20000
- action: assert_not_contains
text: panicked
# Sanity: nothing has been submitted yet, so no session exists.
- action: assert_not_contains
text: dependency graph
- action: focus_prompt
- action: type_text
text: "/graph"
- action: wait
millis: 300
# The dropdown row carries the builtin's description, which only renders
# when /graph is actually advertised pre-session.
- action: assert_contains
text: dependency graph
- action: assert_running
- action: screenshot
name: graph-in-slash-menu-presession
note: "/graph advertised in the slash menu on the welcome screen (KIGI_GRAPH=1), before any prompt."
@@ -0,0 +1,45 @@
name: graph-slash-presession-disabled
description: >
Fail-closed counterpart: with `KIGI_GRAPH=0` (goal harness still on),
`/graph` must NOT appear in the pre-session slash menu, while the menu
itself keeps working for other commands.
terminal:
rows: 40
cols: 100
environment:
env:
- key: KIGI_GOAL
value: "1"
- key: KIGI_GRAPH
value: "0"
mock:
response: "unused — this scenario never submits a prompt."
steps:
- action: wait_for_text
text: Quit
timeout_ms: 20000
- action: assert_not_contains
text: panicked
- action: focus_prompt
- action: type_text
text: "/graph"
- action: wait
millis: 300
- action: assert_not_contains
text: dependency graph
- action: assert_running
- action: screenshot
name: graph-absent-from-slash-menu
note: "/graph hidden with KIGI_GRAPH=0 (fail-closed gate)."
# The menu itself still works: clear "/graph" (6 chars) and try /compact.
- action: keys
keys: "<BS><BS><BS><BS><BS><BS>"
- action: type_text
text: "/compact"
- action: wait
millis: 300
- action: assert_contains
text: Compact conversation history
- action: screenshot
name: slash-menu-still-works
note: "Slash menu functional; only /graph is gated off."
@@ -1,52 +0,0 @@
name: release-notes-scroll
description: >
Open /release-notes (DocViewer modal) and verify keyboard + mouse-wheel scrolling
moves the changelog body. Seeds CHANGELOG cache offline via setup is not available
in YAML, so this scenario relies on CDN/cache if present and still asserts the
modal chrome + scroll affordances. Prefer the pty_e2e Rust tests for deterministic
offline seeding; this YAML exercises the scripted ptyctl runner path.
terminal:
rows: 40
cols: 110
mock:
response: "SCRIPTED_RELEASE_NOTES_SCROLL unused for this scenario."
steps:
- action: wait_for_text
text: Quit
timeout_ms: 20000
# Promote welcome → session and open release notes (uses CDN or disk cache).
- action: type_text
text: "/release-notes"
- action: keys
keys: "<Enter>"
- action: wait_for_text
text: Release Notes
timeout_ms: 20000
- action: assert_contains
text: scroll
# Keyboard scroll through the modal body.
- action: keys
keys: "<Down><Down><Down><Down><Down><Down><Down><Down><Down><Down>"
- action: wait
millis: 200
- action: keys
keys: "jjjjjjjjjj"
- action: wait
millis: 200
# Mouse wheel at the center of the modal.
- action: scroll
row: 20
col: 55
direction: down
count: 15
- action: wait
millis: 250
- action: assert_contains
text: Release Notes
- action: assert_not_contains
text: panicked
- action: keys
keys: "<Esc>"
- action: screenshot
name: release-notes-after-scroll
note: Release notes modal after keyboard + wheel scroll.
@@ -64,12 +64,6 @@ async fn scripted_slash_resize_storm() {
run_scenario("slash_resize_storm.yaml").await; run_scenario("slash_resize_storm.yaml").await;
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_release_notes_scroll() {
run_scenario("release_notes_scroll.yaml").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore] #[ignore]
async fn scripted_mock_response() { async fn scripted_mock_response() {
@@ -345,6 +339,21 @@ async fn scripted_goal_slash_presession() {
run_scenario("goal_slash_presession.yaml").await; run_scenario("goal_slash_presession.yaml").await;
} }
/// `/graph` gating mirror of the `/goal` pre-session scenarios: with
/// `KIGI_GRAPH=1` (+ the goal harness) the command must be advertised on
/// the welcome screen before any session exists.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_graph_slash_presession() {
run_scenario("graph_slash_presession.yaml").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_graph_slash_presession_disabled() {
run_scenario("graph_slash_presession_disabled.yaml").await;
}
/// Counterpart to `scripted_goal_slash_presession`: with the goal flag /// Counterpart to `scripted_goal_slash_presession`: with the goal flag
/// explicitly off (`KIGI_GOAL=0`; goal mode defaults on), `/goal` must stay /// explicitly off (`KIGI_GOAL=0`; goal mode defaults on), `/goal` must stay
/// hidden pre-session (gate fail-closed) while an /// hidden pre-session (gate fail-closed) while an
@@ -522,6 +531,8 @@ fn scenarios_parse() {
"path_space_hyperlink.yaml", "path_space_hyperlink.yaml",
"goal_slash_presession.yaml", "goal_slash_presession.yaml",
"goal_slash_presession_disabled.yaml", "goal_slash_presession_disabled.yaml",
"graph_slash_presession.yaml",
"graph_slash_presession_disabled.yaml",
"folder_trust_prompt.yaml", "folder_trust_prompt.yaml",
"dashboard_model_list_click.yaml", "dashboard_model_list_click.yaml",
"paste_chip_double_click.yaml", "paste_chip_double_click.yaml",
@@ -2090,16 +2090,22 @@ async fn get_apply_context(worktree_path: &str) -> Result<ApplyContext> {
.await? .await?
} }
async fn get_file_at_commit(worktree_path: &str, commit: &str, path: &str) -> Option<String> { /// Raw blob bytes at `commit:path`, or `None` when absent. Byte-exact
git_cli( /// (NOT `git_cli`, which lossy-decodes and trims): the 3-way merge below
Path::new(worktree_path), /// must compare content byte-for-byte or binary files mis-merge.
&["show", &format!("{}:{}", commit, path)], async fn get_file_at_commit(worktree_path: &str, commit: &str, path: &str) -> Option<Vec<u8>> {
) let output = tokio::process::Command::new("git")
.arg("-C")
.arg(worktree_path)
.arg("show")
.arg(format!("{}:{}", commit, path))
.output()
.await .await
.ok() .ok()?;
output.status.success().then_some(output.stdout)
} }
async fn apply_file_content(dest: &Path, content: Option<&String>) -> bool { async fn apply_file_content(dest: &Path, content: Option<&Vec<u8>>) -> bool {
match content { match content {
Some(data) => { Some(data) => {
if let Some(parent) = dest.parent() { if let Some(parent) = dest.parent() {
@@ -2114,6 +2120,14 @@ async fn apply_file_content(dest: &Path, content: Option<&String>) -> bool {
} }
} }
/// Lossy decode for the `FileConflict` wire payload only — comparisons
/// above stay byte-exact.
fn conflict_text(content: &Option<Vec<u8>>) -> Option<String> {
content
.as_ref()
.map(|b| String::from_utf8_lossy(b).into_owned())
}
pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeResponse> { pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeResponse> {
let worktree_path = &req.worktree_path; let worktree_path = &req.worktree_path;
let git_root = find_main_repo_root_from_path(Path::new(worktree_path))?; let git_root = find_main_repo_root_from_path(Path::new(worktree_path))?;
@@ -2133,7 +2147,7 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
for file_change in ctx.changed_files { for file_change in ctx.changed_files {
let worktree_file = Path::new(worktree_path).join(&file_change.path); let worktree_file = Path::new(worktree_path).join(&file_change.path);
let main_file = git_root.join(&file_change.path); let main_file = git_root.join(&file_change.path);
let theirs = tokio::fs::read_to_string(&worktree_file).await.ok(); let theirs = tokio::fs::read(&worktree_file).await.ok();
if req.mode == ApplyMode::Overwrite { if req.mode == ApplyMode::Overwrite {
if apply_file_content(&main_file, theirs.as_ref()).await { if apply_file_content(&main_file, theirs.as_ref()).await {
@@ -2142,21 +2156,28 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
continue; continue;
} }
// Merge mode // Merge mode — 3-way-lite over raw bytes.
let base = get_file_at_commit(worktree_path, &ctx.base_commit, &file_change.path).await; let base = get_file_at_commit(worktree_path, &ctx.base_commit, &file_change.path).await;
let ours = tokio::fs::read_to_string(&main_file).await.ok(); let ours = tokio::fs::read(&main_file).await.ok();
if base == ours { if base == ours {
// Main side untouched: take the worktree's version.
if apply_file_content(&main_file, theirs.as_ref()).await { if apply_file_content(&main_file, theirs.as_ref()).await {
files.push(file_change); files.push(file_change);
} }
} else if ours == theirs {
// Both sides hold identical content (e.g. dirty state the
// worktree inherited at creation, or an earlier sequential
// apply already landed the same change): already present —
// not a conflict, nothing to write.
files.push(file_change);
} else if base != theirs { } else if base != theirs {
conflicts.push(FileConflict { conflicts.push(FileConflict {
path: file_change.path, path: file_change.path,
change_type: file_change.change_type, change_type: file_change.change_type,
base, base: conflict_text(&base),
ours, ours: conflict_text(&ours),
theirs, theirs: conflict_text(&theirs),
}); });
} }
} }
+11 -7
View File
@@ -131,16 +131,20 @@ try {
Write-Host "" Write-Host ""
Write-Host "kigi v$ResolvedVersion installed to $Dest" Write-Host "kigi v$ResolvedVersion installed to $Dest"
# -contains instead of Where-Object/.Count: under Set-StrictMode, .Count
# on an empty (null) filter result throws PropertyNotFoundStrict — which
# fired on every fresh install, since that's exactly the not-on-PATH case.
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") $UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
$OnPath = ($UserPath -split ";" | Where-Object { $_ -eq $BinDir }).Count -gt 0 -or $OnPath = (($UserPath -split ";") -contains $BinDir) -or
($env:Path -split ";" | Where-Object { $_ -eq $BinDir }).Count -gt 0 (($env:Path -split ";") -contains $BinDir)
if (-not $OnPath) { if (-not $OnPath) {
# Persist the bin dir on the per-user PATH so the user doesn't have
# to. Registry-backed; every new terminal picks it up automatically.
$NewUserPath = if ($UserPath) { "$BinDir;$UserPath" } else { $BinDir }
[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")
Write-Host "" Write-Host ""
Write-Host "$BinDir is not on your PATH. Add it for the current user with:" Write-Host "Added $BinDir to your user PATH."
Write-Host "" Write-Host "Open a new terminal, then run 'kigi' to get started."
Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$BinDir;`" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')"
Write-Host ""
Write-Host "Then open a new terminal and run 'kigi' to get started."
} else { } else {
Write-Host "Run 'kigi' to get started." Write-Host "Run 'kigi' to get started."
} }
+40 -4
View File
@@ -191,9 +191,45 @@ case ":$PATH:" in
printf 'Run `kigi` to get started.\n' printf 'Run `kigi` to get started.\n'
;; ;;
*) *)
printf '\n%s is not on your PATH. Add it with:\n\n' "$BIN_DIR" # Persist BIN_DIR on PATH in the login shell's rc file, so the user
printf ' export PATH="%s:$PATH" # sh / bash / zsh (add to your shell rc)\n' "$BIN_DIR" # doesn't have to. Idempotent: skipped when the rc already mentions
printf ' fish_add_path %s # fish\n\n' "$BIN_DIR" # the bin dir. On write failure the manual command is printed and the
printf 'Then run `kigi` to get started.\n' # script fails loudly (the binary itself is already installed).
persist_line() {
rc="$1"
line="$2"
if [ -f "$rc" ] && grep -qF "$BIN_DIR" "$rc"; then
printf '\n%s is already configured in %s.\n' "$BIN_DIR" "$rc"
return 0
fi
printf '\n# Added by the kigi installer\n%s\n' "$line" >> "$rc" \
|| err "could not write $rc — add kigi to your PATH manually: $line"
printf '\nAdded %s to your PATH in %s.\n' "$BIN_DIR" "$rc"
}
EXPORT_LINE="export PATH=\"$BIN_DIR:\$PATH\""
case "${SHELL:-}" in
*/zsh)
persist_line "${ZDOTDIR:-$HOME}/.zshrc" "$EXPORT_LINE"
;;
*/bash)
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
if [ "$PLATFORM_OS" = "macos" ]; then
persist_line "$HOME/.bash_profile" "$EXPORT_LINE"
else
persist_line "$HOME/.bashrc" "$EXPORT_LINE"
fi
;;
*/fish)
# fish_add_path in config.fish is fish's own idempotent way
# to persist a PATH entry.
FISH_CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/fish"
mkdir -p "$FISH_CONF_DIR"
persist_line "$FISH_CONF_DIR/config.fish" "fish_add_path $BIN_DIR"
;;
*)
persist_line "$HOME/.profile" "$EXPORT_LINE"
;;
esac
printf 'Open a new terminal, then run `kigi` to get started.\n'
;; ;;
esac esac