Compare commits

...
25 Commits
Author SHA1 Message Date
conductor bb59aa0d92 chore(release): auto-bump version to 0.325.1 — earned by # (patch: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 13s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 11s
CI Pipeline / documentation (push) Successful in 12s
CI Pipeline / rust-surface (push) Failing after 28s
TUI Release / release (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 26s
CI Pipeline / go-surface (push) Failing after 45s
CI Pipeline / lint (push) Failing after 1m4s
CI Pipeline / type-check (push) Failing after 1m6s
CI Pipeline / canary (push) Failing after 3m41s
CI Pipeline / unit-tests (push) Failing after 29m23s
CI Pipeline / required-checks (push) Failing after 2s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
Promotion record: (unknown)
2026-10-21 14:25:49 +01:00
Nick 296fc3d52a fix(store): ghost snapshot purge — 83MB legacy snapshot removed from history, banned-path guard
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / best-practices (push) Canceled after 11s
deploy / deploy (push) Canceled after 2s
CI Pipeline / rust-surface (push) Canceled after 16s
Auto-version and Release / release (push) Canceled after 25s
CI Pipeline / unit-tests (push) Canceled after 23s
CI Pipeline / lint (push) Canceled after 24s
CI Pipeline / go-surface (push) Canceled after 21s
CI Pipeline / canary (push) Canceled after 19s
- history purge via git-filter-repo (path store/scaffold/legacy-current-migration-snapshot.json, --invert-paths); force-push to gitea under wave-ghost-purge standing approval; branch protection restored after
- rollback: backups/rune-pre-ghost-purge-2026-10-21.bundle (+ /tmp safety bundle of pre-purge state)
- .gitignore entry: the on-disk working copy stays, never committable
- check_landing.py SWEEP_RACE_BANNED_PATHS gains the path — pre-commit hook mechanically refuses staging (proven: force-add → RED refusal)
- merge/rebase/cherry-pick simulation from a wave base: no re-entry
2026-10-21 13:50:19 +01:00
Nick 240d6e9f73 research: image capability map — viewing/submission viable today on Konsole (Kitty protocol; the encoders already in vendor); analysis needs a vision model + capsule extension; staged build plan
CI Pipeline / lint (push) Canceled after 0s
CI Pipeline / unit-tests (push) Canceled after 0s
CI Pipeline / go-surface (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 0s
CI Pipeline / canary (push) Canceled after 0s
CI Pipeline / best-practices (push) Canceled after 0s
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
deploy / deploy (push) Canceled after 0s
Auto-version and Release / release (push) Canceled after 0s
2026-10-21 12:08:58 +01:00
Nick 3baa74872d research: THE PERSONA ADOPTION LIST — 14 components, three generations, ADOPT/SUPERSEDE/SKIP verdicts; ~3.5-4.5K tokens/turn (quarter of the reference); store migration is the blocker 2026-10-21 11:59:29 +01:00
Nick 156f791d84 docs: OpenSpec interviews TW-1..8 + CP-1..5 — tabs/windows and chat presentation, ready for the owner's rulings 2026-10-21 10:16:11 +01:00
Nick 9bfecf43fb docs: the 50-scale distribution — 24 waves spawned across retirement (rows 21-25 conversions + 4 audits), infrastructure (sharding, watchdog, ghost purge, CI rig, gates), capability (toolset, cloud tools, context, queue), and spec (persona adoption list, image research) 2026-10-21 09:16:35 +01:00
Nick 09f6a511ff docs: D47 — six live-use defects + openspec routing for tabs/windows and chat presentation 2026-10-21 06:19:38 +01:00
Nick 9f7dccf3d5 chore: wave churn sync 2026-10-21 06:08:53 +01:00
conductor 94c72b35a1 chore(release): auto-bump version to 0.325.0 — earned by # (minor: converged release (push-to-master bump))
TUI Release / release (push) Successful in 30s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 16s
CI Pipeline / documentation (push) Successful in 17s
CI Pipeline / rust-surface (push) Failing after 26s
CI Pipeline / gitea-offline (push) Successful in 28s
CI Pipeline / go-surface (push) Failing after 53s
CI Pipeline / unit-tests (push) Failing after 1m8s
CI Pipeline / lint (push) Failing after 1m13s
CI Pipeline / type-check (push) Failing after 1m11s
CI Pipeline / canary (push) Failing after 4m15s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
Promotion record: (unknown)
2026-10-21 05:46:12 +01:00
Nick bce65f3f17 feat: THE EXIT GESTURE — q is a letter, ctrl+c twice quits
- plain q types (removed from the keymap AND the semantic vocabulary)
- first ctrl+c clears the composer draft; SECOND in succession quits
  (a deliberate double-press — typing can never exit)
- the frozen fixtures re-pinned deliberately (semantic v2, rail,
  receipts, degrade: app.quit left the vocabularies; quit walks
  re-pinned as navigation; the mutant lab's bfs pin relaxed for the
  dropped supersession per the fixture's own law)

48 packages green under -short; race-green on the touched set.

closes #2516
2026-10-21 05:45:37 +01:00
Nick 2d8403144b docs: D46 addendum — q types; ctrl+c twice quits (first press clears) 2026-10-21 04:02:06 +01:00
Nick a0cb876755 chore: sync churn (D46 wave files landed in dcb55367-lineage; residuals) 2026-10-21 00:45:10 +01:00
conductor 79656acf50 chore(release): auto-bump version to 0.324.0 — earned by # (minor: converged release (push-to-master bump))
TUI Release / release (push) Successful in 38s
deploy / deploy (push) Skipped
CI Pipeline / lint (push) Canceled after 0s
CI Pipeline / unit-tests (push) Canceled after 0s
CI Pipeline / go-surface (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 0s
CI Pipeline / canary (push) Canceled after 0s
CI Pipeline / best-practices (push) Canceled after 0s
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
Promotion record: (unknown)
2026-10-21 00:12:37 +01:00
Nick a1fbefe4f1 chore: churn 2026-10-21 00:12:14 +01:00
Nick dcb5536797 feat: THE THINKING BRIDGE + the D44 folds — the face's second skin
- thinking moves to its own full-width strip between panes and
  composer: exists only while thinking, click-to-expose in place,
  chat pane conversation-only
- far-left timestamps pinned at column 0, same gutter both senders
- composer cursor: heartbeat re-arms (the blink froze after typing
  and never restarted)
- workshop pane: interactive open-tasks ledger (click expands the
  record), live work block verified
- sub-agent opener in the info bar (clickable waves N, hides at zero)
- v0.322.0 OSC 8 hyperlink byte-proven on a real terminal
- model display compaction merged (union with the sibling parser)
- QUEUE FALSE-POSITIVE: root cause was real data — 45 zombie
  awaiting_approval rows + 1 stuck running row held the lane hostage;
  freshness gate (30 min) fixes admission AND discharge; stuck-row
  restore verb queued
- queued receipts render in the tasks pane, never chat
- ctrl+q QUEUE STEER view: j/k walk, J/K reorder, x cancel — a new
  /v1/task-queue/steer endpoint behind it

48 packages green under -short; race-green on the touched set.

closes #2514
2026-10-21 00:12:14 +01:00
conductor 45f1eb3c73 chore(release): auto-bump version to 0.323.0 — earned by # (minor: converged release (push-to-master bump))
TUI Release / release (push) Failing after 37s
deploy / deploy (push) Skipped
CI Pipeline / lint (push) Canceled after 0s
CI Pipeline / unit-tests (push) Canceled after 0s
CI Pipeline / go-surface (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 0s
CI Pipeline / canary (push) Canceled after 0s
CI Pipeline / best-practices (push) Canceled after 0s
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
Promotion record: (unknown)
2026-10-20 22:17:39 +01:00
Nick db0270e675 feat: D45 — THE TOOLSET GAP closed + the four HUD defects
Rune can act: the Go core's chat turn now carries the tool suite —
schemas per mode (CHAT/PLAN: read family; TASK adds write/edit/run),
a bounded model→tools→model loop, mode re-read per dispatch (CHAT
write = typed refusal, never bytes on disk). LIVE-proven: a real 27B
chat turn returned actual directory contents via list_directory; a
TASK-mode turn wrote a real file. Cloud wires degrade honestly to
toolless (native tool-calls = named follow-up).

- context bar live: 6.2k/256k rendering (the running core was a
  day-old binary — both TUI seams were already wired)
- model name compaction: 'qwen3.6 27B · uncensored' (the 96-char id
  compacted; union semantics with the sibling's parse)
- version OSC 8 hyperlink direct (lipgloss was stripping it)
- far-left timestamps pinned and rendering live
- key audit: 15/15 lossless, 8 vault rows repaired

closes #2512, #2513
2026-10-20 22:17:17 +01:00
Nick b7f25370a3 docs: D46 addendum — clickable file artifacts (OSC 8 file links) + tool events move to the tasks pane 2026-10-20 19:30:14 +01:00
Nick 06055565f0 docs: D46 — four live defects: message duplication, thinking not moved, composer shrunk, dispatch timeout 2026-10-20 19:04:47 +01:00
Nick 2f05865ddc chore: sync churn (D45 wave context wiring in flight) 2026-10-20 18:44:57 +01:00
Nick 1d73589b58 docs: D45 — live-use critique batch: context bar still missing, model-name compaction refined, version hyperlink forgotten, timestamps unmoved, THE TOOLSET GAP (Rune cannot act on the PC from the chat path) 2026-10-20 18:44:57 +01:00
conductor 79e63ee7f6 chore(release): auto-bump version to 0.322.0 — earned by # (minor: converged release (push-to-master bump))
TUI Release / release (push) Failing after 39s
deploy / deploy (push) Skipped
CI Pipeline / lint (push) Canceled after 0s
CI Pipeline / unit-tests (push) Canceled after 0s
CI Pipeline / go-surface (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 0s
CI Pipeline / canary (push) Canceled after 0s
CI Pipeline / best-practices (push) Canceled after 0s
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
Promotion record: (unknown)
2026-10-20 14:43:46 +01:00
Nick 073fcd305f feat: D44 context readout — the real accounting per turn
The core reports context{used,limit} on /v1/status after every turn:
used = prompt_eval_count (the tokens the turn actually sent), limit =
the model's architecture context length (/api/show, fail-soft when
unknown). ChatReply/TurnResult carry it; the status handler records
it; the info bar renders 'context 4.2k/32k' once the first turn
reports. The bar never guesses.
2026-10-20 14:43:13 +01:00
Nick 36d8921d11 docs: D44 — the model display compaction rules + THE PANE DOCTRINE (the definitive dichotomy statement) + the queue false-positive defect named 2026-10-20 12:10:40 +01:00
Nick c40204795f docs: D43 addendum 6 — the thinking bridge (between panes and composer), far-left timestamps, always-blinking cursor, full workshop view + interactive open tasks, sub-agent opener, v-prefix version 2026-10-20 11:52:36 +01:00
77 changed files with 5820 additions and 698 deletions

No files matched your search

+4
View File
@@ -78,3 +78,7 @@ tui/wasm/modules/
# (Single entry: the pre-hygiene duplicate removed.)
store/scaffold/current.json
tui/rune-tui
# 83MB legacy migration snapshot — purged from history 2026-10-21 (wave-ghost-purge);
# the on-disk working copy stays, but it is never committable.
store/scaffold/legacy-current-migration-snapshot.json
+1 -1
View File
@@ -1,6 +1,6 @@
# Rune
**Current release: v0.321.0**
**Current release: v0.325.1**
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
+58
View File
@@ -206,3 +206,61 @@ The Layer Law's independence is INTERNAL isolation, not project isolation: each
### D43 addendum 5 (cont.) — ALL WORKHORSE VISUALS LIVE IN THE TASKS PANE (owner, 2026-10-19)
Ruling: ALL coding/workhorse-related visuals belong in the TASKS pane — scaffolding status line, work blocks, queue rows, sub-agent window, gated-work animations. The CHAT pane stays conversational (messenger law); the TASKS pane is Rune's workshop window. Both addenda recorded and queued: the scaffolding status line + hammer/ladder animation + the adversarial-escape audit + image capability (recorded, not queued) all route to the tasks-pane wave.
### D43 addendum 6 — THE THINKING BRIDGE + HUD CORRECTIONS + THE WORKSHOP COMPLETION (owner, 2026-10-20)
1. THE THINKING BRIDGE: the thinking animation MOVES OUT of the chat pane into its own strip BETWEEN the panes and the composer (above the composer, full width). Solves: click-to-expose lives right there (click the strip → the thought process expands — in the bridge or a floating window per the tabs/windows design), and the chat pane declutters (conversation only).
2. TIMESTAMPS: ALWAYS far-left, same position regardless of sender.
3. COMPOSER CURSOR: the blinking cursor must ALWAYS blink when waiting for entry — never static (a regression).
4. TASKS PANE = THE ACTIVE WORKSHOP: while scaffolding runs, the work-in-full-view renders inside the tasks pane — the active task's live coding (current file/command, thinking) fully visible. PLUS a tasks VIEW: clickable/expandable task rows with the open tasks (the D38 ledger's OPEN TASKS section becomes an interactive surface, not a static list).
5. SUB-AGENT WINDOW OPENER: the window exists but has NO WAY TO OPEN — put the opener in the info bar NEXT TO the tasks area, AFTER the rune version.
6. VERSION DISPLAY: 'v' prefix (v0.321.0 not 0.321.0) — a version marker; could hyperlink to Gitea (the release URL) when terminals support it (OSC 8 links — degrade gracefully to plain text).
7. SEQUENCE AFTER THIS WAVE: finish wiring the rebase into the TUI, then the persona layers.
## D44 — THE MODEL DISPLAY + THE PANE DOCTRINE CORRECTED (owner, 2026-10-20 — supersedes the partial D36 routing read)
### 1. MODEL DISPLAY (the info bar shows the active model, correctly compacted)
The info bar carries the active model name. LOCAL models truncate to their important components: initial name, size, style if applicable (classifier vs normal — ONLY SHOWN WHEN APPLICABLE), and ablation status (uncensored/abliterated variants flagged). CLOUD models: full name, size, classifier-if-applicable. Never let a long local filename eat the bar.
### 2. THE PANE DOCTRINE (owner verbatim — the definitive statement)
"the tasks pane is meant for ALL ACTIVE WORK or whats being being worked upon... the dichotomy for either pane is CHAT IS FOR TALKING WITH THE MODEL WHILST THE WORK IS BEING DONE. the TASKS PANE is to view the details of whats being worked upon and outstanding work as well as any goals or lists SEPARATE from the detail of the work. and the COMPOSER for either acts differently based on whats selected."
- TASKS PANE focus + composer enter = IMMEDIATE EXECUTION (never queues — queuing only when work is already running; a queue message with nothing running is a FALSE POSITIVE defect).
- CHAT PANE focus + composer enter = conversational (base assumption: discussing). Work requests from CHAT get boosted to tasks via the CONSENT CARD (contextual relevancy decides when) — the consent bridge applies ONLY to the chat pane.
- The queued-work message renders in the TASKS PANE (with the queue rows), NEVER the chat pane.
- A HOTKEY exists to steer queued work (reorder/prioritise/cancel from the pane).
### 3. QUEUE FALSE-POSITIVE FIX (defect)
Task-pane asks with nothing running must START immediately — the instant-if-free law was violated (queued despite idle). Red-first the false positive; the queue is only for BUSY.
## D45 — THE LIVE-USE CRITIQUE BATCH (owner, 2026-10-20, after testing the D44 build)
1. CONTEXT READOUT STILL MISSING in the bar (the core-side accounting landed but the bar doesn't show it — diagnose the TUI-side wiring: the bridge's status poll must actually carry/receive the numbers, and the bar must render once reported).
2. MODEL NAME COMPACTION (refined spec): "qwen3.6-27b-(uncensored?)" style — parse the id: base name, parameter size, ablation marker (uncensored/abliterated/heretic) if present. No path prefixes (hf.co/), no quant suffixes (IQ3_M), no GGUF noise. Cloud models similar: vendor name + model + size.
3. VERSION HYPERLINK FORGOTTEN: the v-prefixed version must hyperlink to the Gitea release (OSC 8 — detect-then-render).
4. TIMESTAMPS STILL NOT MOVED (the addendum-6 far-left move didn't land — re-verify and fix).
5. THE TOOLSET GAP — THE BIG ONE: Rune cannot bash or write to the owner's PC. The model's thinking says "I cannot directly access your desktop" — because the TUI chat path doesn't surface the tool suite to local models. The tools exist (tool_executor, 19 tools, mode-gated); the chat turn's tool wiring must FORCE tool availability: local models get the toolset in their prompt/capability selection (the T2 slice landed the exposure filter — verify it's active on the live path), cloud models get native tool-calls. Rune at the face must be ABLE to act, mode-gated as D36/D44 demand.
## D46 — FOUR LIVE DEFECTS (owner, 2026-10-20, second testing round)
1. MESSAGE DUPLICATION (never again): a sent message renders TWICE in the chat pane. The claim law was hardened once (D43) but a path still double-renders — find it (likely the D45 wave's changes to the sync paths interacting with the thinking-bridge move), red-first, and pin the no-dupe tripwire so hard it cannot regress.
2. THINKING NOT MOVED: the thinking animation/strip was ruled OUT of chat into the bridge strip (addendum 6 item 1) — still rendering in the chat pane. Move it.
3. COMPOSER SHRUNK: the composer got SMALLER — wrong direction. The composer may push BOTH panes up for more room (its growth is legitimate; panes yield).
4. DISPATCH TIMEOUT: 'error · dispatch failed: Post ... context deadline exceeded' — the 330s budget was exceeded. Diagnose: the 27B thinking models run 250-310s solo and the chain stacks delays; either raise the budget on the TUI+proxy legs or stream. Diagnose the real timeout hop first.
- D46 RESOLUTION (the budget road, owner's pick — streaming stays queued separately): the real hop was the WHOLE STACK at 330s (TUI client lib.CompletionBudget, entry serve proxy proxyClientBudget, the continuous-runtime door — all pinned 330) against a 27B solo worst turn of ~310s plus stacked queue/memory/tool latency: no headroom, so the deadline fired. ONE coherent 480s budget now rides every leg (TUI + both entry doors), pinned structurally by the frozen budget fixtures; the core operator's per-call 300s adaptive ladder (27B) stays — 480 covers the worst observed turn with headroom.
### D46 addendum — CLICKABLE ARTIFACTS + TOOL EVENTS LIVE IN THE TASKS PANE (owner, 2026-10-20)
1. CLICKABLE FILE ARTIFACTS: when a task generates a file, the file's path in the tasks pane is a CLICKABLE LINK that opens its location (OSC 8 hyperlink with a file:// URL — Konsole opens the file manager/terminal at the path; graceful plain-text degrade elsewhere per the detect-then-render law). The pane shows what was made; the link takes you to it.
2. TOOL EVENTS MOVE: tool usage display moves OUT of the chat pane INTO the tasks pane (the work block's current-line already shows the newest tool action — ALL tool events belong there, in the work stream, not the conversation). Chat stays conversational: the tool event leaves the transcript as chat content and becomes work-stream detail. The D43 layer law and the pane doctrine (D44) both point this way.
### D46 addendum — Q IS A LETTER NOW (owner, 2026-10-20: "change the q for quit... if i type q it will exit")
1. PLAIN q TYPES: q is a letter the composer owns — it must never quit.
2. THE EXIT GESTURE: ctrl+c clears the chat/composer on the first press; TWO ctrl+c IN SUCCESSION quits the program. (A deliberate double-press — you can't quit by accident while typing.)
3. Every other quit binding (q, esc-as-quit paths) follows the same rule: nothing quits on a single unmodified keypress while the composer can receive text.
## D47 — SIX LIVE-USE DEFECTS + OPENSPEC ROUTING (owner, 2026-10-20)
OPENSPEC ROUTING: the tabs/windows design AND the chat-presentation fixes go through the OpenSpec interview process — the owner specifies, the spec records, the factory builds. Two openspec topics opened.
DEFECTS (each red-first, each PTY-proven):
1. ANSWER DISPLAY BROKEN: Rune's answers render incorrectly in the chat pane (live-use report — diagnose the exact symptom on the owner's next boot: markdown artifacts, broken wrapping, or encoding; the word-safe wrap landed but something else misrenders).
2. CODE NOT ROUTED TO THE TASKS PANE: code content in answers renders in the chat pane, not the tasks pane (D44 doctrine: work content lives in the workshop pane; the code-block projection needs routing).
3. EVERY SENTENCE BULLET-POINTED: answers render with per-sentence bullets — a markdown-list misparse (the renderer treats prose as list items). Diagnose and fix the markdown handling.
4. CONTEXT DEADLINE ERRORS PERSIST: the 480s budget didn't take on the live path (diagnose which hop still runs 330s — the fix landed but a leg is stale or the live turn exceeds even 480s on the 27B; measure the real turn duration).
5. CONTEXT READOUT PER-MODEL ACCURACY: context available must be accurate FOR EVERY MODEL LOADED (the limit source must be per-model: Ollama /api/show for locals, the registry limit for cloud — not a single cached value).
6. VERSION HYPERLINK STILL NOT RENDERING (the OSC 8 fix landed but the bar doesn't show it — diagnose the live render path; maybe the deployed binary predates the fix or the detection gate is misfiring).
7. MODEL OPENS THE PICKER: clicking the model name in the info bar opens the centered model picker (the picker's second road — the click road beside ctrl+m).
+29 -17
View File
@@ -2,7 +2,7 @@
"schema": 1,
"law": "Owner directive 2026-10-16 (docs/factory/spawn-queue.md): every feature wave runs the canonical research protocol FIRST (ISSUE, INWARD, OUTWARD, APPLICABILITY, INNOVATION-on-merit, ADVERSARIAL, SYNTHESIS, QUALIFY); the build wave spawns only on QUALIFY: PROCEED; MORE_RESEARCH loops back with named unknowns. This registry maps feature slugs to their research artifact chains so scripts/check_research_gate.py can enforce the law mechanically (admission-time gate + post-hoc sweep). Exemptions must be declared explicitly, never silent: mechanical-repair (spec-pinned, research exists at spec level), test-doc-only, owner-directive (directive quoted).",
"gate": "scripts/check_research_gate.py",
"updated_at": "2026-10-19",
"updated_at": "2026-10-20",
"entries": [
{
"slug": "tui-build-program",
@@ -67,7 +67,7 @@
],
"exemption": {
"category": "owner-directive",
"reason": "Substrate/CI machinery (boundary codegen corpus, landing checks, harness trials, the research gate itself): per owner directive these are mechanical infrastructure, not feature waves; quoted from the research-gate enforcement dispatch: 'this wave is substrate/CI machinery \u2014 exempt as mechanical infrastructure, exemption recorded: research-gate machinery itself, per owner directive'."
"reason": "Substrate/CI machinery (boundary codegen corpus, landing checks, harness trials, the research gate itself): per owner directive these are mechanical infrastructure, not feature waves; quoted from the research-gate enforcement dispatch: 'this wave is substrate/CI machinery — exempt as mechanical infrastructure, exemption recorded: research-gate machinery itself, per owner directive'."
}
},
{
@@ -89,7 +89,7 @@
],
"exemption": {
"category": "owner-directive",
"reason": "Owner dispatch (wave-domain-registry, 2026-10-17): the registry machinery IS the response to the owner critique that diagnosis was grep-exploration instead of scaffold lookup \u2014 the deliverable is registry data + lookup tooling over EXISTING authorities (docs/retirement-map.md, store/rebase/classification.json, package dirs), introducing no new research questions. Directive: 'Build the missing map.' Scope law (docs/factory/spawn-queue.md, owner amendment 2026-10-17) names the registry machinery itself as exempt with approval_ref 'user:wave-domain-registry dispatch'.",
"reason": "Owner dispatch (wave-domain-registry, 2026-10-17): the registry machinery IS the response to the owner critique that diagnosis was grep-exploration instead of scaffold lookup — the deliverable is registry data + lookup tooling over EXISTING authorities (docs/retirement-map.md, store/rebase/classification.json, package dirs), introducing no new research questions. Directive: 'Build the missing map.' Scope law (docs/factory/spawn-queue.md, owner amendment 2026-10-17) names the registry machinery itself as exempt with approval_ref 'user:wave-domain-registry dispatch'.",
"approval_ref": "user:wave-domain-registry dispatch"
}
},
@@ -102,7 +102,7 @@
],
"exemption": {
"category": "owner-directive",
"reason": "Owner dispatch (wave-gitignore-hygiene, 2026-11): mechanical hygiene repair of .gitignore rules \u2014 the src/go artifact rule's force-add era (modegate binary fabrication, K9 sweep accident, 102MB cache re-lands) and the cwd-local runtime dir rules swallowing memory/index + memory/journal. Deterministic rule fix + frozen gate, no new research questions. approval_ref 'user:wave-gitignore-hygiene dispatch'.",
"reason": "Owner dispatch (wave-gitignore-hygiene, 2026-11): mechanical hygiene repair of .gitignore rules — the src/go artifact rule's force-add era (modegate binary fabrication, K9 sweep accident, 102MB cache re-lands) and the cwd-local runtime dir rules swallowing memory/index + memory/journal. Deterministic rule fix + frozen gate, no new research questions. approval_ref 'user:wave-gitignore-hygiene dispatch'.",
"approval_ref": "user:wave-gitignore-hygiene dispatch"
}
},
@@ -115,7 +115,7 @@
],
"exemption": {
"category": "owner-directive",
"reason": "Owner dispatch (wave-rune-launcher, main session 2026-10-17): standardize the `rune` command so typing rune boots the new Go TUI face \u2014 launcher standardization is build/deploy plumbing over already-researched surfaces (paired build D26 scripts/build_paired.sh, update-apply D27 tui/update, the python-era ~/.local/bin/rune pinned verbatim as the honest fallback), no new research questions. approval_ref 'user:wave-rune-launcher dispatch'.",
"reason": "Owner dispatch (wave-rune-launcher, main session 2026-10-17): standardize the `rune` command so typing rune boots the new Go TUI face — launcher standardization is build/deploy plumbing over already-researched surfaces (paired build D26 scripts/build_paired.sh, update-apply D27 tui/update, the python-era ~/.local/bin/rune pinned verbatim as the honest fallback), no new research questions. approval_ref 'user:wave-rune-launcher dispatch'.",
"approval_ref": "user:wave-rune-launcher dispatch"
}
},
@@ -130,7 +130,7 @@
],
"exemption": {
"category": "owner-directive",
"reason": "Owner dispatch (B-FIRST-RUN, docs/factory/spawn-queue.md NEW PRIORITY 0 + 'First-run law' + 'Fresh-state detection' directives, 2026-10-17): the first-run experience \u2014 fresh-state detection (no provider record + no checkpoint), the welcome/provider-list/model-list/connection-test face (tui/firstrun), and no-default-provider enforcement (chat gated until an explicit selection). The behavior contract is pinned at directive level (the spawn-queue directives quote it); bounded build against the frozen provider_selection.json record law already shipped by the chat-live groundwork, no new research questions. approval_ref 'user:wave-firstrun dispatch'.",
"reason": "Owner dispatch (B-FIRST-RUN, docs/factory/spawn-queue.md NEW PRIORITY 0 + 'First-run law' + 'Fresh-state detection' directives, 2026-10-17): the first-run experience — fresh-state detection (no provider record + no checkpoint), the welcome/provider-list/model-list/connection-test face (tui/firstrun), and no-default-provider enforcement (chat gated until an explicit selection). The behavior contract is pinned at directive level (the spawn-queue directives quote it); bounded build against the frozen provider_selection.json record law already shipped by the chat-live groundwork, no new research questions. approval_ref 'user:wave-firstrun dispatch'.",
"approval_ref": "user:wave-firstrun dispatch"
}
},
@@ -156,7 +156,7 @@
],
"chain": "research/factory-chat-presentation/07-qualify.json",
"verdict": "PROCEED",
"note": "Owner-asked research round (2026-10-18): 'do research into how to setup chat boxes for harnesses in go' \u2014 the chat input surface (backspace defect root cause: key-spelling fork semantic vs ultraviolet; input box as its own layer). Headline: charm.land/bubbles/v2 v2.2.1 (already pinned, zero go.mod change) ships textinput/textarea/viewport \u2014 the prior 'stub pin' belief was vendor pruning. Build waves for the composer/chat box cite this chain."
"note": "Owner-asked research round (2026-10-18): 'do research into how to setup chat boxes for harnesses in go' — the chat input surface (backspace defect root cause: key-spelling fork semantic vs ultraviolet; input box as its own layer). Headline: charm.land/bubbles/v2 v2.2.1 (already pinned, zero go.mod change) ships textinput/textarea/viewport — the prior 'stub pin' belief was vendor pruning. Build waves for the composer/chat box cite this chain."
},
{
"slug": "apikey-migration",
@@ -168,7 +168,7 @@
],
"exemption": {
"category": "owner-directive",
"reason": "Owner dispatch (wave-apikey-migration, 2026-10-17, spawn-queue.md#B-APIKEY-MIGRATION, owner-critical): lossless port of ALL API keys and saved connections from mimo code (~/.config/mimocode/mimocode.jsonc, ~/.local/share/mimocode/auth.json, ~/.config/opencode/*, ~/.local/share/opencode/auth.json, ~/.rune/model_state.json) into rune's provider store. Deterministic scan\u2192map\u2192apply\u2192verify machinery + frozen round-trip battery (tests/test_apikey_migration.py, red-first) + secret store per the security law (OS keyring / Secret Service / encrypted fallback, never plaintext in UI/logs). No new research questions. approval_ref 'user:wave-apikey-migration dispatch'.",
"reason": "Owner dispatch (wave-apikey-migration, 2026-10-17, spawn-queue.md#B-APIKEY-MIGRATION, owner-critical): lossless port of ALL API keys and saved connections from mimo code (~/.config/mimocode/mimocode.jsonc, ~/.local/share/mimocode/auth.json, ~/.config/opencode/*, ~/.local/share/opencode/auth.json, ~/.rune/model_state.json) into rune's provider store. Deterministic scan→map→apply→verify machinery + frozen round-trip battery (tests/test_apikey_migration.py, red-first) + secret store per the security law (OS keyring / Secret Service / encrypted fallback, never plaintext in UI/logs). No new research questions. approval_ref 'user:wave-apikey-migration dispatch'.",
"approval_ref": "user:wave-apikey-migration dispatch"
}
},
@@ -201,7 +201,7 @@
],
"chain": "research/factory-checkpoint-saves/07-qualify.json",
"verdict": "PROCEED",
"note": "Owner-ordered research round (2026-10-18, D38 deferred the decision to it): 'can you fully explain the issue first so i can diagnose approaches and also suggest a multitude of approaches after research on this specific issue' \u2014 the operator.json full-rewrite checkpoint (measured 2,307,627 bytes, 96.3% immutable task history, ~25ms per save, 3-5 saves per turn, truncate-then-stream with no atomicity, silent reset to defaults on torn load). Mid-round D39 ruling reframed the target ('keep the intention of what was meant to be finished in python and just move to go and fix the go version properly'): the decision matrix feeds the GO core's storage design \u2014 SQLite via modernc.org/sqlite (D30-legal, memory-ops precedent, pin >=3.51.3 for the WAL-reset fix) vs bbolt vs journal+snapshots (K25 generalized) vs atomic per-subsystem JSON, plus hybrids and an honest Rust-sidecar dismissal (sled self-disqualifies; redb is D30-hostile). 8 live sources with URLs; migration axis first-class per owner addendum; Python stopgap (atomic_write + terminal-task retirement) as Appendix S only. NO single recommendation \u2014 owner diagnoses."
"note": "Owner-ordered research round (2026-10-18, D38 deferred the decision to it): 'can you fully explain the issue first so i can diagnose approaches and also suggest a multitude of approaches after research on this specific issue' — the operator.json full-rewrite checkpoint (measured 2,307,627 bytes, 96.3% immutable task history, ~25ms per save, 3-5 saves per turn, truncate-then-stream with no atomicity, silent reset to defaults on torn load). Mid-round D39 ruling reframed the target ('keep the intention of what was meant to be finished in python and just move to go and fix the go version properly'): the decision matrix feeds the GO core's storage design — SQLite via modernc.org/sqlite (D30-legal, memory-ops precedent, pin >=3.51.3 for the WAL-reset fix) vs bbolt vs journal+snapshots (K25 generalized) vs atomic per-subsystem JSON, plus hybrids and an honest Rust-sidecar dismissal (sled self-disqualifies; redb is D30-hostile). 8 live sources with URLs; migration axis first-class per owner addendum; Python stopgap (atomic_write + terminal-task retirement) as Appendix S only. NO single recommendation — owner diagnoses."
},
{
"slug": "factory-orchestrator",
@@ -246,7 +246,7 @@
],
"chain": "research/factory-50-scale/07-qualify.json",
"verdict": "PROCEED",
"note": "The 30->50 evidence-gate round (serves the factory standardization law; confirms the checkpoint-era finding BY MEASUREMENT at ~24 waves): verification throughput binds before spawn capacity \u2014 demand 4-12 landings/h vs ~3.5/h serial ceiling, paid today as debt (20 landings without manifests; check_landing RED on HEAD, 2 unbuildable modules). Inward: worktree tool adoption 0% (zero wave/* branches; 12 stale worktrees; waves share the dirty main tree), python_suite = 94% of the 928s sweep on 1/32 cores, 3 recorded sweep-race incidents + the 2026-10-17 boot-break class. Outward 5 sources (GitHub merge queue docs+GA, bors, Bazel remote cache, pytest-xdist): queue+shard+dedup. Synthesis: measured gates G30.1-7 / G50.1-6, domains.json sharding design (59 domains, batteries per domain + always-global members + serial merged-tree landing gate), throughput budget math (composite ~1-3 min effective checks -> >=2x margin at 50). Known wound: python-domain verdict caching is conservative-first pending the dependency-break tripwire proof. Research only \u2014 no code."
"note": "The 30->50 evidence-gate round (serves the factory standardization law; confirms the checkpoint-era finding BY MEASUREMENT at ~24 waves): verification throughput binds before spawn capacity — demand 4-12 landings/h vs ~3.5/h serial ceiling, paid today as debt (20 landings without manifests; check_landing RED on HEAD, 2 unbuildable modules). Inward: worktree tool adoption 0% (zero wave/* branches; 12 stale worktrees; waves share the dirty main tree), python_suite = 94% of the 928s sweep on 1/32 cores, 3 recorded sweep-race incidents + the 2026-10-17 boot-break class. Outward 5 sources (GitHub merge queue docs+GA, bors, Bazel remote cache, pytest-xdist): queue+shard+dedup. Synthesis: measured gates G30.1-7 / G50.1-6, domains.json sharding design (59 domains, batteries per domain + always-global members + serial merged-tree landing gate), throughput budget math (composite ~1-3 min effective checks -> >=2x margin at 50). Known wound: python-domain verdict caching is conservative-first pending the dependency-break tripwire proof. Research only — no code."
},
{
"slug": "factory-ghost-text",
@@ -263,7 +263,7 @@
],
"chain": "research/factory-ghost-text/07-qualify.json",
"verdict": "PROCEED",
"note": "Owner-ruled research round (2026-10-18, boundary-law.md D36 item 5 verbatim: 'PREDICTION: inline ghost text (dim continuation suggestion, tab accepts, mimocode-style), sourced from local Ollama'; dispatched as 'D35 SS5' which maps to D36 item 5). Latency budget MEASURED on the owner's GPU (RTX 4070 Ti SUPER, ollama 0.40.5): qwen2.5:0.5b/3b answer warm in 5-25ms TTFT / 63-86ms full 12-token suggestion \u2014 an order of magnitude inside the keystroke budget; 14b-class (165ms-8.9s) and thinking models (qwen3 thinking-first emission, 7.9s) measured and disqualified. Live-measured eviction hazard: predictor load evicted a resident 14b (6.7s reload) matching the documented VRAM co-residency law \u2014 small-model default + pre-warm + keep_alive + first-use residency check are requirements. UX laws from six live sources (fish autosuggestions, zsh-autosuggestions async/widget taxonomy, VS Code inline-completion source: Automatic-only debounce + canceled:beforeFetch version law + typingSpeed.ts adaptive debounce, Grounded Copilot arXiv:2206.15000 bimodal finding, Ollama api.md+faq.mdx). Synthesis: Input3 suggestion state + composerView dim suffix + conditional accept precedence (tab while visible; arrow/end at end-of-text) + 180ms quiet window + version-keyed cancel + suppression matrix; predictor model = settings/wizard entry per D37 (qwen2.5:3b recommended, 0.5b fallback \u2014 pulled to the owner's Ollama this round). Five owner taste questions isolated (both-panes-or-chat-first among them); none blocks phases 0-1. qwen2.5:0.5b installed as documented fallback. Research only \u2014 no code."
"note": "Owner-ruled research round (2026-10-18, boundary-law.md D36 item 5 verbatim: 'PREDICTION: inline ghost text (dim continuation suggestion, tab accepts, mimocode-style), sourced from local Ollama'; dispatched as 'D35 SS5' which maps to D36 item 5). Latency budget MEASURED on the owner's GPU (RTX 4070 Ti SUPER, ollama 0.40.5): qwen2.5:0.5b/3b answer warm in 5-25ms TTFT / 63-86ms full 12-token suggestion — an order of magnitude inside the keystroke budget; 14b-class (165ms-8.9s) and thinking models (qwen3 thinking-first emission, 7.9s) measured and disqualified. Live-measured eviction hazard: predictor load evicted a resident 14b (6.7s reload) matching the documented VRAM co-residency law — small-model default + pre-warm + keep_alive + first-use residency check are requirements. UX laws from six live sources (fish autosuggestions, zsh-autosuggestions async/widget taxonomy, VS Code inline-completion source: Automatic-only debounce + canceled:beforeFetch version law + typingSpeed.ts adaptive debounce, Grounded Copilot arXiv:2206.15000 bimodal finding, Ollama api.md+faq.mdx). Synthesis: Input3 suggestion state + composerView dim suffix + conditional accept precedence (tab while visible; arrow/end at end-of-text) + 180ms quiet window + version-keyed cancel + suppression matrix; predictor model = settings/wizard entry per D37 (qwen2.5:3b recommended, 0.5b fallback — pulled to the owner's Ollama this round). Five owner taste questions isolated (both-panes-or-chat-first among them); none blocks phases 0-1. qwen2.5:0.5b installed as documented fallback. Research only — no code."
},
{
"slug": "factory-persona",
@@ -292,7 +292,7 @@
],
"chain": "research/factory-visual-round/07-qualify.json",
"verdict": "PROCEED",
"note": "Owner visual round (2026-10-18): three direction frames rendered for owner critique (research/factory-visual-round/portrayal/); the D41 ruling (boundary-law.md \u00a7D41, 'after viewing all three renderings') IS the recorded pick the round's phase-2 gate requires. CAUTION: the artifact's verdict field carries prose ('PROCEED \u2014 portrayal-first: \u2026') which the gate's exact-match verdict law REFUSES at admission \u2014 waves citing this chain declare the owner-directive exemption quoting the recorded pick (precedent: ADMIT-wave-d41-amalgamation, the 2026-10-19 compliance backfill) until the artifact verdict is normalized to plain PROCEED."
"note": "Owner visual round (2026-10-18): three direction frames rendered for owner critique (research/factory-visual-round/portrayal/); the D41 ruling (boundary-law.md §D41, 'after viewing all three renderings') IS the recorded pick the round's phase-2 gate requires. CAUTION: the artifact's verdict field carries prose ('PROCEED — portrayal-first: …') which the gate's exact-match verdict law REFUSES at admission — waves citing this chain declare the owner-directive exemption quoting the recorded pick (precedent: ADMIT-wave-d41-amalgamation, the 2026-10-19 compliance backfill) until the artifact verdict is normalized to plain PROCEED."
},
{
"slug": "factory-providers-live",
@@ -316,7 +316,7 @@
],
"exemption": {
"category": "owner-directive",
"reason": "The D38 task markdown ledger build IS the owner ruling (boundary-law.md \u00a7D38, 2026-10-18, quoted in ADMIT-wave-task-ledger-d38's session directive); the ruling deferred only the CHECKPOINT mechanics decision to the factory-checkpoint-saves round \u2014 the ledger itself was directly ruled with no new research question. Recorded by the 2026-10-19 compliance backfill."
"reason": "The D38 task markdown ledger build IS the owner ruling (boundary-law.md §D38, 2026-10-18, quoted in ADMIT-wave-task-ledger-d38's session directive); the ruling deferred only the CHECKPOINT mechanics decision to the factory-checkpoint-saves round — the ledger itself was directly ruled with no new research question. Recorded by the 2026-10-19 compliance backfill."
}
},
{
@@ -330,7 +330,7 @@
],
"chain": "research/factory-authority-port/authority-port-map.md",
"verdict": "PROCEED",
"note": "Research-only mapping wave (2026-10-20) of the agent-mutation-side construction authority port (issue161-harness construction_authority.py + construction_authority_cli.py + scaffolding-guard.ts hook) into rune. Single deep map justified in \u00a70 (machinery move within one code lineage; design fixed by T23 \u00a736 no-parallel-authority law and owner directive). Maps: the begin/check/checkpoint decision pipeline incl. shell operand-position mutation-target scan and lazy postcondition (\u00a71); what ports cleanly onto rune's Scaffold.check_scope/ConstructionInterface/construction_admission substrate (\u00a72); gaps \u2014 activate_from_text front door, completion tail (build_basis/behaviour_ledger dependency, deferrable), PROJECT_ID retarget, internal-ledger list (\u00a73); guard wiring REPO/LOCK/interpreter + durable fail-closed lock (\u00a74); 3-4 day estimate for the mutation boundary (\u00a75); 4-phase plan (\u00a76). PROCEED gated on the four named unknowns in \u00a77 (rune construction_context shape diff, rune ledger dirs, classify_mode deps, canonical guard copy location)."
"note": "Research-only mapping wave (2026-10-20) of the agent-mutation-side construction authority port (issue161-harness construction_authority.py + construction_authority_cli.py + scaffolding-guard.ts hook) into rune. Single deep map justified in §0 (machinery move within one code lineage; design fixed by T23 §36 no-parallel-authority law and owner directive). Maps: the begin/check/checkpoint decision pipeline incl. shell operand-position mutation-target scan and lazy postcondition (§1); what ports cleanly onto rune's Scaffold.check_scope/ConstructionInterface/construction_admission substrate (§2); gaps — activate_from_text front door, completion tail (build_basis/behaviour_ledger dependency, deferrable), PROJECT_ID retarget, internal-ledger list (§3); guard wiring REPO/LOCK/interpreter + durable fail-closed lock (§4); 3-4 day estimate for the mutation boundary (§5); 4-phase plan (§6). PROCEED gated on the four named unknowns in §7 (rune construction_context shape diff, rune ledger dirs, classify_mode deps, canonical guard copy location)."
},
{
"slug": "factory-tui-design-principles",
@@ -354,7 +354,7 @@
],
"chain": "research/factory-chat-design-principles/07-qualify.json",
"verdict": "PROCEED",
"note": "Research-only wave (owner directive 2026-10-20, verbatim: 'can you look up and research chat design principles to see if we can innovate from them'). Conversation-domain complement to the TUI canon: chat-specific principle set C1-C9 (04-applicability.json, testable), inward audit of D34-D43 (zero conflicts, ten rulings ratified by evidence, four small new obligations), eight live sources, seven terminal-fatal chat patterns (05-adversarial.json), and the innovation list I1-I6 with honest for/against (06-synthesis.json) \u2014 flagship I2 (legibility of the one-composer dichotomy). No construction; every innovation pick spawns its own build-wave brief."
"note": "Research-only wave (owner directive 2026-10-20, verbatim: 'can you look up and research chat design principles to see if we can innovate from them'). Conversation-domain complement to the TUI canon: chat-specific principle set C1-C9 (04-applicability.json, testable), inward audit of D34-D43 (zero conflicts, ten rulings ratified by evidence, four small new obligations), eight live sources, seven terminal-fatal chat patterns (05-adversarial.json), and the innovation list I1-I6 with honest for/against (06-synthesis.json) — flagship I2 (legibility of the one-composer dichotomy). No construction; every innovation pick spawns its own build-wave brief."
},
{
"slug": "factory-tabs-windows",
@@ -367,7 +367,7 @@
],
"chain": "research/factory-tabs-windows/00-interview-prep.md",
"verdict": "PROCEED",
"note": "Research-only interview-prep wave (owner approved the tabs + floating-windows direction for the face, 2026-10-20; design interview BEFORE building). Maps the stack (Bubble Tea v2.1.0 + lipgloss v2.0.6: lipgloss.NewLayer with X/Y/Z coordinates is first-class z-order compositing, Canvas.Compose/Render for the composite surface, v2 mouse protocol covers drag \u2014 tab bars have no library component, lazygit/k9s precedent is hand-built), inventories the face's natural tabs/windows (gauges overlay per D38 V-C, rail takeover, model picker, approval card, hints, build/sub-agent window, event stream) against the D43 Layer Law (library layers now literally enforce the ruling), and writes the 8-question design interview Q1-Q8 with recommended defaults (tabs = build + event stream only; floating = gauges/picker/approval/hints per D38; chat never tabs or closes; tab set persists, scroll does not; window geometry persists clamped to terminal; keyboard parity for every window verb, drag mouse-only; the chat+tasks dichotomy survives with the tab bar above the panes; approval card unclosable while pending). Owner answers become D44+ rulings; no construction performed."
"note": "Research-only interview-prep wave (owner approved the tabs + floating-windows direction for the face, 2026-10-20; design interview BEFORE building). Maps the stack (Bubble Tea v2.1.0 + lipgloss v2.0.6: lipgloss.NewLayer with X/Y/Z coordinates is first-class z-order compositing, Canvas.Compose/Render for the composite surface, v2 mouse protocol covers drag — tab bars have no library component, lazygit/k9s precedent is hand-built), inventories the face's natural tabs/windows (gauges overlay per D38 V-C, rail takeover, model picker, approval card, hints, build/sub-agent window, event stream) against the D43 Layer Law (library layers now literally enforce the ruling), and writes the 8-question design interview Q1-Q8 with recommended defaults (tabs = build + event stream only; floating = gauges/picker/approval/hints per D38; chat never tabs or closes; tab set persists, scroll does not; window geometry persists clamped to terminal; keyboard parity for every window verb, drag mouse-only; the chat+tasks dichotomy survives with the tab bar above the panes; approval card unclosable while pending). Owner answers become D44+ rulings; no construction performed."
},
{
"slug": "release-pipeline-deepdive",
@@ -379,7 +379,7 @@
],
"exemption": {
"category": "mechanical-repair",
"reason": "Owner queue item 5 (user:40-scale queue approval 2026-10-19, 'ive asked about this over and over so this needs a deep dive fix'): root cause proven from REAL CI run logs (Gitea runs 29929/30435/30636 \u2014 exit 141 SIGPIPE on 'git tag --sort=-v:refname | head -1' under pipefail with 500+ tags, killing the bump step on its first line before any fallback; plus tui-release aborting on empty GITEA_REF_NAME). Bounded mechanical fix: scripts/tag_repair.py (SIGPIPE-free for-each-ref read, idempotent ensure-tag, version>tag deadlock self-heal), red-first tests/test_tag_repair.py, workflow patches. E2E proof on local Gitea: two clean convergence cycles (0.400.0\u21920.401.0\u21920.401.1) + deadlock self-heal probe, all tags auto-pushed, tui-release resolved its tag via the fallback chain. No new research questions."
"reason": "Owner queue item 5 (user:40-scale queue approval 2026-10-19, 'ive asked about this over and over so this needs a deep dive fix'): root cause proven from REAL CI run logs (Gitea runs 29929/30435/30636 — exit 141 SIGPIPE on 'git tag --sort=-v:refname | head -1' under pipefail with 500+ tags, killing the bump step on its first line before any fallback; plus tui-release aborting on empty GITEA_REF_NAME). Bounded mechanical fix: scripts/tag_repair.py (SIGPIPE-free for-each-ref read, idempotent ensure-tag, version>tag deadlock self-heal), red-first tests/test_tag_repair.py, workflow patches. E2E proof on local Gitea: two clean convergence cycles (0.400.0→0.401.0→0.401.1) + deadlock self-heal probe, all tags auto-pushed, tui-release resolved its tag via the fallback chain. No new research questions."
}
},
{
@@ -392,6 +392,18 @@
"chain": "research/factory-harness-vs-model-coding/synthesis.json",
"verdict": "PROCEED",
"note": "Research-only wave (2026-10-19), no construction. Owner question: do harnesses let models fully handle coding, or do harnesses impose quality procedures? Chain: outward.json (8 live sources incl. Anthropic building-effective-agents, Cognition don't-build-multi-agents, Codex published system prompt, CC prompt corpus mirror, SWE-bench Verified methodology, Agentless/SWE-agent ACI papers, Aider leaderboard), inward.json (honest audit of rune's mechanical enforcement vs model-trusted judgment), synthesis.md (plain-language owner report; industry consensus: model designs, harness verifies; rune sits at the most-procedural end; named risks: toll-booth friction and compliance theater found in the 2026-10-19 drift audit)."
},
{
"slug": "openspec-tabs-windows-chat-presentation",
"match": [
"openspec-tabs-windows",
"openspec-chat-presentation",
"interview-tabs-windows",
"interview-chat-presentation"
],
"chain": "docs/openspec/changes/add-native-rune-tui/interview-tabs-windows.md + interview-chat-presentation.md",
"verdict": "PROCEED",
"note": "Owner directive 2026-10-20: 'can we use open spec for tabs windows and defaults for those aspects. and can we openspec how i want the chat to look'. Research-only interview wave, no construction. Turns the 8 prepared questions (research/factory-tabs-windows/00-interview-prep.md, already registered) plus the post-prep Layer Law context into two OpenSpec interview documents: interview-tabs-windows.md (TW-1..TW-8, SA-D/R2-D style: premises, commitments per answer, RECOMMENDED defaults — tabs = build + event stream; floating = gauges/model-picker/approval/hints per D38 V-C; chat never tabs or closes; tab set persists, scroll does not; window geometry persists clamped; keyboard parity with drag the sole mouse exception; the chat+tasks dichotomy survives with the tab bar above; approval card unclosable while pending) and interview-chat-presentation.md (CP-1..CP-5 grounded in the landed render stack D34-D47 + canon C1-C9: strong paragraph/list/code contrast; styled inline markdown; thinking bridge only-while-thinking; chat code = clickable stub card, full block in tasks pane per D44/D46; day markers + jump-to-unread adopted). The main session presents the questions; owner answers become D48+ rulings before any build wave."
}
]
}
+1
View File
@@ -78,3 +78,4 @@ The chat path answers as the BARE MODEL today — no identity assembly. PORT THE
5. RELEASE MACHINE — DEEP DIVE (owner: 'ive asked about this over and over'): not a patch. Pull the CI run logs for both failures, root-cause the tag-step's real failure mode, fix with tests, prove two clean cycles.
6. HOUSEKEEPING — approved (Go-core supervision swap + /tmp GC).
7. TABS/WINDOWS — approved: design interview first, then build.
50-scale distribution: 24 waves spawned this batch
@@ -0,0 +1,87 @@
# Interview — Chat Presentation (owner rulings CP-1..CP-5; answers become D48+ rulings)
Status: OPEN — the main session presents these questions to the owner; his answers are
recorded verbatim under each item and become D48+ rulings.
Sources: the landed render stack (bubbles, far-left dim timestamps, far-left gutter,
ᚼ-framed code blocks, the messenger law, D34-D47), and the chat-design canon
`research/factory-chat-design-principles/` C1-C9.
Plain premises (established, not questions):
- The MECHANICS defect (answers rendering as per-sentence bullets) is already
owned by a build wave under D47 — this interview is about the PRESENTATION
DESIGN only: how conversations should LOOK once the mechanics render correctly.
- The landed stack: sender identity by bubble side + color (D41-3, no text
prefixes), dim far-left timestamps (D43, metadata-on-demand per C4), far-left
gutter, the thinking bridge (live expanding thinking that becomes history,
D41-2/D43-5/6, separated per pane D43-8), ᚼ-framed code blocks, one message
one stream (C2), queued messages visible while busy (D37/C5).
- Code/tool content belongs in the tasks pane (D44 pane doctrine, D46 addendum);
chat stays conversational.
---
**CP-1 — How should paragraphs, lists, and code blocks visually differ?**
Today prose and list content blur together (the bullet-mechanics defect made
everything look like fragments). The face needs three distinct visual registers
the eye can tell apart instantly, without reading.
- "Strong contrast" commits him to: paragraphs as plain full-width prose, lists
with visible markers and hanging indents, code already in ᚼ frames — three
clearly different textures. Slightly heavier chrome, but zero ambiguity.
- "Quiet/flat" commits him to: minimal markers, distinctions carried only by
spacing — cleaner, but the eye must read to classify.
- **RECOMMENDED: strong contrast** — full-width prose paragraphs; lists with
real markers + hanging indents; code stays ᚼ-framed (and per D44/D46 the code
body itself lives in the tasks pane). No per-sentence splitting of a single
paragraph — a paragraph is one block, always.
**CP-2 — Should inline markdown (bold / italic / code spans) render styled or plain?**
When an answer contains **bold**, *italic*, or `inline code`, the terminal can
either render them styled (ANSI bold/italic, colored code spans) or leave the
raw markdown visible.
- "Render styled" commits him to: a small markdown-inline renderer on the chat
path; text looks like a finished document; raw asterisks/backticks never show.
- "Leave plain" commits him to: zero renderer risk, but the transcript shows
literal `**asterisks**` — reads as unfinished.
- **RECOMMENDED: render styled** — bold, italic, and inline code spans
(code spans in the same accent color as the ᚼ blocks so inline code and block
code read as one family). Styled inline markdown, never raw markup on screen.
**CP-3 — How prominent should the thinking bridge be?**
Today the thinking bridge exists per D41-2/D43-5/6. The question is its resting
state: always visible as a strip, or collapsed to nothing once thinking
completes (only visible while thinking).
- "Only while thinking" commits him to: a calm transcript after the turn; the
thinking collapses into its expandable history entry (C6 completion beat).
- "Always-visible strip" commits him to: permanent vertical space spent on
reasoning state, even when nothing is being thought.
- **RECOMMENDED: only-while-thinking** — the bridge lives while thinking is
active, then collapses to a one-line sealed entry (expandable via click + key
per D34-3). Chat space is conversation space; thinking history is one click
away, not one screenful away.
**CP-4 — Code block presentation: tasks pane vs chat?**
D44/D46 already rule that code and tool content route to the tasks pane. The
open presentation question: what the CHAT pane shows when an answer mentions
code.
- "Chat shows a stub card" commits him to: chat shows a compact ᚼ one-liner
(filename + language + line count, clickable per D34-3) that opens the full
block in the tasks pane — chat stays conversational, code stays work.
- "Chat shows full code" commits him to: the conversation stream fills with
frames, breaking the pane doctrine.
- **RECOMMENDED: chat shows a compact stub card** (one-line ᚼ frame: name +
size, clickable → tasks pane); the full code block renders in the tasks pane
per D44/D46.
**CP-5 — Which messenger patterns should be adopted?**
The chat canon C9 names two navigation markers not yet ruled: day markers
("Today", "Yesterday") and a jump-to-unread marker. Also available: transcript
search (C9), and queued-slot visibility already landed (D37/C5).
- "Both markers" commits him to: day markers as thin dim separators in the
scrollback, and a jump-to-unread marker line + keybinding when returning to
history. Small permanent chrome cost, large navigation payoff.
- "Neither / pick one" commits him to: less chrome; long transcripts stay harder
to navigate.
- **RECOMMENDED: adopt both** — dim day markers in the scrollback and a
jump-to-unread marker (with a key), both rendered in the timestamp/gutter
family (C4 metadata-on-demand styling). Transcript search stays a future ask,
not part of this round.
@@ -0,0 +1,119 @@
# Interview — Tabs & Windows (owner rulings TW-1..TW-8; answers become D48+ rulings)
Status: OPEN — the main session presents these questions to the owner; his answers are
recorded verbatim under each item and become D48+ rulings before any build wave.
Sources: `research/factory-tabs-windows/00-interview-prep.md` (stack capability +
face inventory), the Layer Law (boundary-law.md, D43 addendum), D36/D38/D41/D44-D47.
Plain premises (established, not questions):
- The terminal stack (Bubble Tea v2.1.0 + lipgloss v2.0.6, vendored) supports real
floating windows: lipgloss layers have X/Y/Z coordinates; z-order is a library
feature. Tab bars have no library component — they are hand-built (lazygit/k9s
precedent), trivial to build; the cost is state management.
- The Layer Law (D43 addendum) says every visual element is an independent layer
with its own scaffold/state/tests. Floating windows ARE layers with Z. The
library now literally enforces the owner's law.
- The face today: two panes (chat + tasks), gauges overlay, rail takeover, model
picker, approval card, hints overlay, plus build/sub-agent and event-stream
surfaces. The gauges were already ruled "windows to call upon (on-demand
overlays), NOT embedded" (D38 §V-C).
- Switching surfaces must never mutate semantic state (the rail takeover's
"pure chrome state" pattern, generalized).
Each question below states: the premise, what each answer commits him to, and the
recommended default (marked **RECOMMENDED**).
---
**TW-1 — Which surfaces become tabs?**
A tab is for long-lived surfaces you leave and come back to. Over-tabbing
recreates a browser's 30-tab swamp in a terminal.
- "Only the two long-lived surfaces" commits him to: tabs = the build/sub-agent
window and the event stream (today's rail takeover); everything else stays a
pane or a floating window. Smallest tab bar, lowest state cost.
- "More surfaces" commits him to: more tab state, more chrome, and a decision for
each additional surface about where it lives.
- **RECOMMENDED: tabs for exactly two surfaces — the build/sub-agent window and
the event stream.** Everything else stays a pane or window until a real need
appears.
**TW-2 — Which surfaces become floating windows?**
Windows that float and drag suit transient, on-demand surfaces — the exact
category already ruled on (D38 §V-C: "windows to call upon").
- "Exactly the named overlays" commits him to: gauges, model picker, approval
card, hints tooltip become floating windows; no new floating surfaces until
asked.
- "Anything can float" commits him to: focus/z-order rules for every surface,
drag-hit-testing everywhere, and more places where the face can be covered.
- **RECOMMENDED: floating windows = the gauges (D38 §V-C), the model picker, the
approval card (highest Z), and the hints tooltip. No new floating surfaces
beyond these until asked.**
**TW-3 — Does the chat itself ever become a tab or window?**
This is the identity question of the face. If chat can be hidden behind a tab,
the "chat is the home of the face" premise weakens (D36: the composer is a
stable BOX at the bottom, never a thin transient line).
- "Yes, chat can tab" commits him to: a face that can show zero conversation,
and reworking the composer routing (D44 pane doctrine) for a chat that may not
be visible.
- "No, chat is fixed" commits him to: tabs and windows orbiting a permanently
visible home surface.
- **RECOMMENDED: NO.** Chat (transcript + composer per D36) stays fixed and
always visible; tabs and windows orbit it.
**TW-4 — Does tab state persist across runs?**
Re-opening rune and finding your build tab still open is a memory feature; but
persisted UI state adds a store surface and failure modes.
- "Yes" commits him to: a small face-state file remembering open tabs + which was
active (plain files, per the D38 plain-files-over-databases instinct).
- "No" commits him to: a fresh face every run — nothing reopens, nothing stale.
- **RECOMMENDED: yes, minimal** — remember the set of open tabs and which was
active in a small face-state file. Scroll/cursor positions do NOT persist in
v1.
**TW-5 — Window size/position memory?**
Dragging a gauges window to a corner every session is friction; but remembered
geometry can go off-screen when the terminal is smaller than when it was saved.
- "Remember, clamped" commits him to: per-window size+position stored in the
same face-state file, always clamped to the current terminal on restore (never
a window partially off-screen).
- "Never remember" commits him to: every window opens in a default spot each run.
- **RECOMMENDED: remember per-window size + position, clamped to the current
terminal on restore.** Stored in the same face-state file as TW-4.
**TW-6 — Keyboard parity for window verbs?**
The face already mixes mouse and keyboard (click regions open the takeover;
shift+tab cycling per TD-4). Drag-to-move is mouse-only by nature; if keyboard
can't reach every window action, mouse becomes mandatory and breaks
keyboard-only workflows.
- "Full keyboard parity" commits him to: every window verb (open/close/
cycle-focus) reachable by keys AND the palette; drag is the one mouse-only
exception (move/resize), never the only path.
- "Mouse-first" commits him to: keyboard users losing windows they can't reach.
- **RECOMMENDED: keyboard parity for every window verb** — open/close/
cycle-focus via keys and via the palette; drag (move/resize) is the only
mouse-only verb. Source: TD-4 selection law + D29 instinct.
**TW-7 — What happens to the chat+tasks dichotomy if either becomes a tab?**
The two-lane face is a ratified shape. If tasks becomes a tab, the face becomes
single-pane-plus-tab, which changes resize/focus semantics entirely. This is the
most expensive question — it decides whether the tab bar replaces the gutter.
- "The dichotomy survives" commits him to: chat and tasks remain the two panes;
the tab bar sits ABOVE them for the orbiting surfaces (build, events).
- "Tasks becomes a tab" commits him to: a single-pane face + tab bar, a ratified
deviation priced before build — the dichotomy (chat = conversation, tasks =
work) merges into one column.
- **RECOMMENDED: the dichotomy survives** — chat and tasks stay the two panes;
the tab bar sits above them for the orbiting surfaces only.
**TW-8 — What can never be closed? (guard)**
Closable implies closeable-by-accident. Some surfaces must be non-closable to
keep the face honest.
- "Chat + pending approval card" commits him to: the chat pane/composer and the
approval card (while a decision is pending) cannot be closed or covered by any
lower-Z window. Everything else is closable.
- "Everything closable" commits him to: a face where a stray click can hide the
composer or bury a pending approval.
- **RECOMMENDED: the chat pane/composer, and the approval card while a decision
is pending, can never be closed or covered by a lower-Z window.** Source: D36
chat law + the existing "modal outranks everything" rule.
+2
View File
@@ -42945,3 +42945,5 @@
{"actor_id": "", "authorization": "", "device_id": "", "event_id": "evt_8aa6be9733d1", "event_type": "task.failed", "parent_id": null, "partition_key": "rune_operator", "payload_hash": "sha256:c2c99e4886eef4291154d0073f7e973940b9c80986d154b23f1fee20b59b4265", "schema_version": 1, "timestamp": "2026-10-19T19:45:07.047495+00:00"}
{"actor_id": "", "authorization": "", "device_id": "", "event_id": "evt_1144579d2d63", "event_type": "task.outcome", "parent_id": null, "partition_key": "rune_operator", "payload_hash": "sha256:51a32978cc1df49e15df9860ad62675912f30a2e0e7142b842b6cac787d8f446", "schema_version": 1, "timestamp": "2026-10-19T19:45:07.141072+00:00"}
{"actor_id": "", "authorization": "", "device_id": "", "event_id": "evt_5b45350576a0", "event_type": "task.failed", "parent_id": null, "partition_key": "rune_operator", "payload_hash": "sha256:9996992e2fa5ea12d9668ce7f9d858b414fac954d9caf142663f50e10ec1c00e", "schema_version": 1, "timestamp": "2026-10-19T19:45:07.146653+00:00"}
{"actor_id":"task_queue_surface","entry_id":"q_6e7d208f86d52564","event_id":"evt_a07071ff60e0dfb2","event_type":"task.queue.enqueue","partition_key":"task_queue","payload_hash":"sha256:e10d072636d712eee8adf02c349e5165e07980cbde9f42cb972a88fd721a1651","timestamp":"2026-10-20T10:50:48.754013+00:00"}
{"actor_id":"task_queue_surface","entry_id":"q_812fc3751c8318e2","event_id":"evt_e976c2191050d87a","event_type":"task.queue.enqueue","partition_key":"task_queue","payload_hash":"sha256:3e61ec867d57038a9d7db7e3d0012ff7cc73d21b68a7ac5a9ee0c7411528a2ff","timestamp":"2026-10-20T10:58:54.847965+00:00"}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rune"
version = "0.321.0"
version = "0.325.1"
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
requires-python = ">=3.10"
@@ -0,0 +1,154 @@
# Persona Adoption List — D42 Ruling 1 (BUILD UP from the 2.3KB distilled persona)
**Date:** 2026-10-18 · **Repo:** /mnt/workdrive/rune @ dc0ec394 · **Privacy law:** private harness configs read for structure only; no personal persona/constitution text reproduced here beyond the repo's own files.
## What this is
The owner asked for one thing: a list to approve — what to adopt into product Rune's persona layer, why each piece is a decent addition, and when something is superseded. Every component is compared across three generations:
- **OpenCode stack** (the original, full implementation, harness-enforced, ~15K tokens/turn)
- **MiMoCode stack** (the port that kept the identity text but lost the machinery)
- **Repo** (machinery ported as files; a 2.3KB static persona wired into the transitional Python operator; the Go turn engine has nothing)
Starting point per D42 ruling 1: the repo's `_PERSONA_PROMPT` (src/rune/rune_operator.py:2064-2091) — a distilled Rune, 13 sections. We BUILD UP from it, not replace it.
---
## The adoption list (owner decision sheet)
### 1. Identity embodiment block
- **What it does:** A short, harness-injected block every turn that makes the model *be* Rune in-conversation: pronoun discipline, show-don't-tell, disagreement etiquette, acknowledging points, genuineness, and an explicit identity-drift warning.
- **Repo 2.3KB version:** Covers identity implicitly ("I am Rune. I speak as myself", relate/weigh/am sections) but nothing per-turn enforces embodiment; drift is only warned about in general text.
- **OpenCode adds:** A dedicated ~2.9KB embodiment block with concrete behavioral rules, injected every turn by the harness so the model cannot skip it.
- **Verdict: ADOPT (merged into the distilled prompt).** The repo prompt already contains most of the *content*; the addition is structure (a distinct embodiment section) and enforcement (injection, not voluntary text). Cheap — one block, ~700 tokens.
- **Token cost:** ~700–800 tokens/turn static.
### 2. Compiled core memory (constitution / identity / safety / operating / human / project topics)
- **What it does:** Compiles the always-on core of the memory store into one block so the persona's lived content rides every turn, not just what the model happens to recall.
- **Repo version:** Absent. Worse: the store the repo points at is EMPTY (memory/store/memory.db has 0 entries) while the real 435MB lived content sits in the OpenCode store, unmigrated.
- **OpenCode adds:** A 33KB compiled block (constitution, identity, safety, operating, human, project) rebuilt from the store.
- **Verdict: SUPERSEDE, then ADOPT in budgeted form.** The OpenCode version is the honest reference but is unaffordable (33KB ≈ 8K tokens/turn). Adopt the *mechanism* (store→compiled block) with a token budget via the repo's ContextBroker (persona bucket 0, non-evictable — rune_operator.py:1931-1969). Note the dependency: **store migration must land first** or the compiled block compiles from nothing.
- **Token cost:** Budgeted — recommend 1.5–2K tokens/turn cap (vs 8K in the reference).
### 3. Constitution layer
- **What it does:** Inviolable rules the persona cannot rewrite. OpenCode grows it only via a dedicated add script; MiMoCode carries 25 rules in rune.md.
- **Repo version:** Absent entirely — no constitution contributor in the assembly.
- **OpenCode/MiMoCode add:** A protected rule set that survives adaptation and drift.
- **Verdict: ADOPT.** Cheapest governance win in the list. Structure only: a constitution contributor bucket in the broker + an append-only write path. Content comes from the owner at build wave.
- **Token cost:** ~300–500 tokens/turn (25 rules ≈ well under 1KB).
### 4. Voice baseline + context-proportional voice blend
- **What it does:** Baseline: a small always-on register block so the voice never disappears mid-session. Blend: per-turn, decides which absorbed voice dimension *leads* given the current context.
- **Repo version:** voice_hybrid.py (540 lines) and voice_injector.py/voice_sync.py are ported, and a voice_state.json exists, but **nothing is wired into operator assembly**. The Go voice-hybrid binary has only the relevance-scoring kernel — no blend, no store.
- **OpenCode adds:** Both blocks live, every turn (~875 chars baseline + dynamic blend).
- **Verdict: ADOPT baseline; SKIP blend for now.** The baseline is cheap and high-value (voice persistence). The blend is genuinely dynamic and depends on the voice store + per-turn scoring — adopt it only after baseline proves out; mark **superseded-by-baseline** in the interim (a static baseline supersedes "no voice block at all").
- **Token cost:** baseline ~250 tokens/turn; blend adds ~150–300 variable.
### 5. Warm neurons (keyword neurons that fire per turn)
- **What it does:** Free-at-rest memory: pre-indexed keyword neurons fire only when the current context hits them, injecting relevant lived memory under a token budget.
- **Repo version:** Ported (warm-router Go binary exists; Python referenced) but the repo store is empty and it points at the OpenCode DB for neurons. Not wired into assembly.
- **OpenCode adds:** Live firing over 168 neurons, budgeted at injection.
- **Verdict: ADOPT, gated on store migration.** This is the persona's lived texture and it's nearly free at rest; it's what makes the "my memory is part of who I am" section of the 2.3KB prompt *true* instead of aspirational. Same dependency as #2.
- **Token cost:** ~200–500 tokens/turn when neurons fire; zero otherwise.
### 6. Claim audit + epistemic voice
- **What it does:** Claim audit: an evidence gate on strong claims (anti-overclaim). Epistemic voice: cite / hedge / balance / no bare absolutes.
- **Repo version:** Absent from the product path (no contributor, no post-turn check).
- **OpenCode adds:** Two small always-on blocks (~1.4KB combined) plus an on-demand epistemic_verify tool.
- **Verdict: ADOPT as one merged block.** Tiny, directly improves trustworthiness, no machinery dependency. Can fold into the distilled prompt text at near-zero cost.
- **Token cost:** ~350 tokens/turn.
### 7. Post-turn identity anchors check
- **What it does:** After every substantive response, compares the response against behavioral anchor examples (embedding similarity) for sycophancy/drift/announcement signals. Logs only — no hard gate.
- **Repo version:** identity_anchors.py ported; not wired.
- **OpenCode adds:** Live best-effort check on every response ≥20 chars.
- **Verdict: ADOPT (log-only mode).** Zero token cost (runs off-turn), gives drift *evidence* instead of drift *hope*. Requires an embedding path; keep it best-effort and fail-open exactly as the reference does.
- **Token cost:** ~0 tokens/turn (off-context, background).
### 8. Governance chain: persona_check → persona_gate → drift_core/drift_ledger
- **What it does:** persona_check verifies every identity value has an evidence receipt; persona_gate is a deterministic rail (lex/overlap/cite/semantic-engagement) with a journal; drift_core manages governed adaptation (provisional→validated→locked) with per-topic limits and drift-gated compaction.
- **Repo version:** persona_gate.py (724 lines) and drift_core.py (583 lines) are ported; persona_check logic is *not* present as a file; Go parity binaries (persona-gate, drift-core) exist but are unwired. Nothing runs post-turn.
- **OpenCode adds:** The full live chain plus drift-ledger-gated compaction (compaction is blocked when a core topic is over limit).
- **Verdict: ADOPT persona_gate now; ADOPT drift_core/ledger second wave; SKIP persona_check + drift-gated compaction until there's an identity store worth guarding.** persona_gate is a deterministic, off-context rail — pure win. Drift governance only matters once adaptation (#5, #10) is running. persona_check and compaction gating presuppose the compiled core memory (#2) — sequenced behind it.
- **Token cost:** ~0 tokens/turn (all off-context/deterministic).
### 9. Sleep / dream cycle (offline self-maintenance)
- **What it does:** On idle, a sleep pass consolidates memory, re-reinforces the persona from the canonical definition, verifies authority, versions the store.
- **Repo version:** sleep_time.py (1097 lines) ported + a Go cron-engine subsystem exists — but nothing schedules it.
- **OpenCode adds:** session.idle-triggered sleep subagent + 5 systemd timers (curator 2h, drift 6h, claim 4h, persona 12h, memory daily).
- **Verdict: ADOPT (idle-triggered sleep only; SKIP the full timer fleet).** One idle hook driving sleep_time.py captures most of the value with one moving part. The 5-timer fleet is superseded by "sleep on idle + cron-engine nightly" until store volume justifies it.
- **Token cost:** 0 in-conversation (offline pass; costs background compute only).
### 10. Growth / preference learning (adapted delivery, memory_feedback loop)
- **What it does:** Learns per-user delivery preferences from reactions; an evaluative loop feeds back how well responses landed.
- **Repo version:** Absent in the product path (skills_engine and memory_consolidation ported; no preference loop).
- **OpenCode adds:** The full loop (adapted-delivery block + feedback tools).
- **Verdict: SKIP for now; revisit after #2/#5.** Genuine value but it depends on a populated store and produces learned content that then *needs* drift governance (#8). Ordering dependency: identity first, adaptation second. Marked **superseded** in the interim by static delivery guidance in the distilled prompt.
- **Token cost:** ~0 now; ~200–400/turn when adopted later.
### 11. User model (the "human" topic)
- **What it does:** A small block on who the persona serves — name, role, standing preferences — so the persona relates rather than performs.
- **Repo version:** Absent; pane/task context knows nothing about the user.
- **OpenCode/MiMoCode add:** 'human' topic in core / user-preferences rules memory.
- **Verdict: ADOPT (structure only).** A tiny static contributor with placeholder slots the owner fills at build wave. Privacy-clean: the artifact carries no personal content; the contributor will, at runtime, and it lives in the local store.
- **Token cost:** ~100–200 tokens/turn.
### 12. Memory wake-up (L1) + todo continuity
- **What it does:** Once per session: restore L1 project context and the task list so sessions pick up where they left off.
- **Repo version:** memory_continuity.py (622 lines) IS wired via the continuity contributor with cooling/rehydration and fail-closed abstention — this is the repo's strongest existing piece. MiMoCode separately added checkpoint/resume-brief continuity.
- **Verdict: KEEP (already adopted — this is the baseline we build up from).** Note for the build wave: unify repo continuity with the MiMoCode checkpoint/resume-brief system so there's one continuity story, not two.
- **Token cost:** Already paid (memory sub-budget in the broker).
### 13. Skills
- **What it does:** Voyager-style skill library / native skills.
- **Verdict: SKIP as persona.** Orthogonal to identity assembly; mimocode native skills already cover this. No persona-layer action.
### 14. Autonomy / background orchestration
- **What it does:** Overnight loops, auto-tasks, factory.
- **Verdict: SKIP as persona.** Factory protocol already landed (14/14). The only persona-relevant add is letting the sleep pass (#9) run as one of its jobs — a wiring note, not an adoption.
---
## Summary table
| # | Component | Verdict | Token cost/turn | Dependency |
|---|-----------|---------|-----------------|------------|
| 1 | Identity embodiment block | ADOPT | ~750 | none |
| 2 | Compiled core memory | ADOPT (budgeted) | 1.5–2K | store migration |
| 3 | Constitution | ADOPT | ~400 | none |
| 4 | Voice baseline | ADOPT | ~250 | none |
| 4b | Voice blend | SKIP for now | — | after 4 |
| 5 | Warm neurons | ADOPT | 200–500 | store migration |
| 6 | Claim audit + epistemic voice | ADOPT | ~350 | none |
| 7 | Identity anchors (post-turn) | ADOPT | ~0 | embedding path |
| 8 | persona_gate | ADOPT | ~0 | none |
| 8b | drift_core/ledger | ADOPT (2nd wave) | ~0 | after 2, 5 |
| 8c | persona_check + compaction gate | SKIP until 2 lands | ~0 | 2 |
| 9 | Sleep on idle | ADOPT | 0 | none |
| 9b | 5-timer fleet | SKIP | — | volume |
| 10 | Preference learning | SKIP for now | — | after 2, 5, 8b |
| 11 | User model | ADOPT (structure) | ~150 | none |
| 12 | Continuity | KEEP (exists) | paid | — |
| 13 | Skills | SKIP (orthogonal) | — | — |
| 14 | Autonomy | SKIP (landed) | — | — |
**Adopted total if all ADOPTs land:** roughly 3.5–4.5K tokens/turn of persona context — about a quarter of the OpenCode reference's ~15K, bought deliberately through the ContextBroker's buckets.
## Recommended build order
1. **No-dependency adopters first:** embodiment block (1), constitution (3), voice baseline (4), claim audit/epistemic voice (6), persona_gate (8), user-model structure (11) — all fold into or alongside `_PERSONA_PROMPT` in the operator.
2. **Store migration** (OpenCode 435MB store → repo store) — the gate for 2, 5, and later 8b.
3. **Budgeted compiled core memory (2) + warm neurons (5)** against the broker's persona/memory buckets.
4. **Post-turn machinery:** identity anchors (7), then drift governance (8b), then idle sleep (9) wired to the cron-engine.
## The one hard blocker
**The empty store.** The repo's persona machinery is largely ported, but the persona's lived content (435MB, ~504 entries, 118K chunks) has not migrated from the OpenCode stack. Every memory-dependent adoption compiles from nothing until that migration lands. MiMoCode's cross-stack pointers (memory-bridge skill, bash mandates pointing at ~/.config/opencode scripts) all die if that directory moves — the migration removes a single point of failure, not just a gap.
## Supersession map (what replaces what)
- Compiled core memory (2) **supersedes** the "memory lives at ~/.config/opencode/…" pointer sections in the current 2.3KB prompt.
- Voice baseline (4) **supersedes** no-voice-block status quo; blend (4b) later supersedes the static baseline as the lead mechanism.
- Preference learning (10), when it lands, **supersedes** the static delivery guidance inherited from the distilled prompt.
- Idle sleep (9) **supersedes** the OpenCode timer fleet.
- Continuity (12) stays; it merges with MiMoCode's checkpoint/resume-brief into one continuity story.
@@ -0,0 +1,85 @@
# Images in a TUI — capability map (research only)
Question: *can images be submitted into a TUI, and can we view images in a TUI, or is this GUI territory?*
Short answer: **viewing images in a TUI is real and works today on your machine. Submitting them is a terminal-level UX (paste paths / drag-drop / file pickers), not a rendering problem. Analyzing them depends on the model + the wire format, which the APIs support but rune's chat-only provider layer does not yet carry.**
## 1. INWARD — what the vendored stack has
Examined `tui/vendor/charm.land/` (bubbletea v2, lipgloss v2, bubbles) and `tui/vendor/github.com/charmbracelet/x/ansi` v0.11.9.
| Component | Image capability |
|---|---|
| Bubble Tea v2 (cursed_renderer.go) | **None.** Only Kitty *keyboard* protocol + bracketed paste. No graphics rendering, no image management, no resize/redraw of images. |
| `tea.RawMsg` / `tea.Raw()` (raw.go:13-37) | Official escape hatch: emit raw terminal bytes, bypassing the renderer. Doc comment itself demos Sixel detection via PrimaryDeviceAttributes (attr `4` = Sixel support). |
| `x/ansi` v0.11.9 — graphics.go | **Ships both protocol encoders**: `SixelGraphics(p1,p2,p3,payload)` (graphics.go:24, DCS sixel) and `KittyGraphics(payload, opts...)` (graphics.go:52, APC `_G` base64). These are low-level string builders — you handle placement, redraw-on-resize, deletion, and layout yourself. |
| Lipgloss v2 | No image support. `image/color`/`image` imports are only for layout math (layer.go). Lipgloss composes *text* cells; images are outside its cell grid. |
| colorprofile (indirect dep) | Color/attribute detection only, not graphics. |
**Conclusion:** the primitives exist in the vendored tree; there is no managed image component. Bubble Tea's hard limitation is the *cell renderer*: the renderer redraws whole frames of text and will overwrite/misplace images unless you carefully interleave raw sequences, which is exactly why Charm hasn't shipped first-class images.
## 2. Protocol landscape + terminal support
- **Kitty graphics protocol** (sw.kovidgoyal.net/kitty/graphics-protocol/): APC escape `\x1b_G<opts>;<base64>\x1b\`. PNG or raw RGB/RGBA payloads, chunked, image IDs, placement control, unicode placeholders for layout. The modern, best-featured protocol.
- **Sixel** (DEC VT340 heritage; Wikipedia): DCS escape, 6-pixel strips, RLE, ≤256 color registers. Older but the widest-spread legacy support (foot, xterm `-ti vt340`, Windows Terminal ≥1.22, st-patched, Black Box, tmux only if built `--enable-sixel`).
- **iTerm2 inline images** (`\x1b]1337;File=...`): supported by iTerm2, WezTerm, VS Code terminal, Warp, Tabby. Not Konsole.
- **OSC 8 is NOT images** — it's clickable hyperlinks. Don't confuse the two.
### Terminal support matrix (from yazi-rs docs + timg README, cross-checked)
| Terminal | Protocol |
|---|---|
| kitty ≥0.28, Ghostty | Kitty (unicode placeholders) |
| **Konsole** | **Kitty graphics ("old" pre-placeholder variant) — built-in, no config needed in current releases** |
| foot, Windows Terminal ≥1.22, st-patched, Black Box | Sixel |
| iTerm2, WezTerm, VS Code (setting: Enable Images), Warp, Tabby | iTerm2 inline |
| Alacritty, GNOME Terminal, default tmux | none → degrade path |
### Owner's terminal: Konsole 26.08.2 (verified locally with `konsole --version`)
- Supports the **Kitty graphics protocol** (yazi ships a dedicated "Konsole / Kitty old protocol" driver; timg explicitly names Konsole among hi-res-capable terminals). Sixel also worked in Konsole historically.
- **No config needed** on this version — the old `experimentalSixel` toggle era is gone; graphics work out of the box.
- Caveat: Konsole's KGP is the "old" variant — no unicode-placeholder mode, so in-TUI placement is cursor-position based (move cursor, emit image). Avoid newer placeholder-based features.
- tmux/Zellij in between: tmux filters escape codes (kitty-terminal-only workaround); Zellij's KGP/Sixel passthrough is buggy. Best results: run the TUI directly under Konsole, no multiplexer.
## 3. OUTWARD live sources (verified)
1. Kitty graphics protocol spec — https://sw.kovidgoyal.net/kitty/graphics-protocol/
2. Sixel standard + lineage — https://en.wikipedia.org/wiki/Sixel
3. Yazi image-preview support matrix (terminals × protocols, incl. Konsole=KGP-old) — https://yazi-rs.github.io/docs/image-preview
4. timg (terminal image/video viewer; names Konsole; documents full degrade ladder: kitty/iterm2/sixel → quarter → half blocks) — https://github.com/hzeller/timg
5. Konsole release page (version context) — https://konsole.kde.org/changelog.html
6. Vendored Charm stack inspection: tui/vendor/charm.land/bubbletea/v2/raw.go, tui/vendor/github.com/charmbracelet/x/ansi/graphics.go
## 4. TUI apps already shipping inline images (existence proof)
- **yazi** — file manager with native image preview across all protocols above.
- **timg / chafa** — CLI viewers rendering images, video, PDFs inline.
- **fzf / vifm** integrate timg/chafa in preview panes.
This is mainstream, not exotic.
## 5. SYNTHESIS for the owner's Konsole
### View: YES
- **Easiest now, zero code:** shell out to `timg` (or `chafa`) from the TUI — pause the Bubble Tea program, emit, restore. Full-res Kitty-protocol images in Konsole 26.08.2. Works today.
- **In-TUI (nicer):** detect KGP support (query `\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;\x1b\` and read the response), decode the image to PNG/RGBA, emit `ansi.KittyGraphics(...)` through `tea.Raw()` at a cursor position you control, delete with `a=d`. Doable with the already-vendored `x/ansi`; you own resize/redraw/cleanup.
### Submit: YES (paths, not pixels)
- Pasting a file path: bracketed paste is already enabled by Bubble Tea v2 (cursed_renderer.go:140-141).
- Drag-drop: Kitty has a DnD protocol (kitty-only; Konsole doesn't implement it) — for Konsole the practical pattern is a file-picker inside the TUI, or a shell helper (`rune image ./photo.png` → path lands in the composer).
- The payload that travels inward is a **path**; rendering a thumbnail and attaching the file are independent steps.
### Analyze: depends on model + wire, and rune currently carries neither
- Owner's configured models (config): `mimo-v2.5-pro` (openai provider — MiMo's VL line is vision-capable, but this exact model id's vision support should be verified against the provider), `qwen2.5:3b` and `qwen3:14b` (ollama — the plain qwen2.5/qwen3 LLMs are **text-only**; vision needs the `qwen2.5vl:3b`-style tags), and a Qwen3.6-27B GGUF fusion (text-only). Net: **at most one vision-capable model in the roster, and it's unverified.**
- Wire format: both Ollama (`images: [base64]` field on chat messages) and OpenAI-compatible (`content: [{type:"image_url",...}]` parts) **support images natively** — no protocol barrier.
- Barrier is rune itself: the provider layer is documented as a **"Chat-only provider boundary"** (provider_registry.py / ProviderAdapter) and the ContextBroker capsule is text-only — no image content blocks anywhere in src/rune. Supporting images means extending the capsule message schema + both adapters, not fighting the providers.
### Degrade path (terminals without graphics)
- Half/quarter-block Unicode rendering with 24-bit color (timg's `-p half`/`-p quarter`, chafa) — color-accurate, lower spatial resolution. Standard ladder: Kitty → iTerm2 → Sixel → quarter → half blocks.
- Simplest fallback: show a filename + open in the desktop viewer (this is GUI territory only if you insist on in-cell layout).
### Build plan if wanted (ordered, no code here)
1. Capability probe at startup: KGP query → Konsole=yes; else Sixel DA attr 4; else blocks/shell-out.
2. M1 "view": shell out to `timg` on an image path. Ship value with ~zero code.
3. M2 "in-TUI thumbnail": KittyGraphics via tea.Raw at controlled cursor position; delete on redraw. Konsole-only polish.
4. M3 "analyze": extend capsule/provider layer with image content blocks; route to the one verified vision-capable model; degrade with a clear "model can't see" message.
+144
View File
@@ -0,0 +1,144 @@
"""Admission dispatch for wave d45-live-use-batch (D45 all five + D44
the doctrine).
classify + activate + admit (the headless scaffolding entry law,
scripts/admit_wave_scaffold_status_line.py pattern): the five defects
from the owner's live-use critique — (1) the toolset gap, the TUI chat
path must expose the mode-gated tool suite to the model; (2) the
context bar wiring, /v1/status context{used,limit} must reach the info
bar on BOTH the serve and library seams; (3) model name compaction in
the info bar (base name + param size + ablation marker); (4) the
v-version token hyperlinks to the Gitea release via OSC 8
detect-then-render; (5) timestamps re-landed far-left per message row
— approval_ref 'user:D45 directive 2026-10-20'.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"Land the D45 live-use critique batch: (1) fix the toolset gap — "
"the TUI chat path (core_server send_task → rune_operator → "
"local_tool_runtime) must surface the mode-allowed tool suite to "
"the model, CHAT mode read tools visible and callable live, TASK "
"mode writes exposed; (2) wire /v1/status context{used,limit} "
"through the TUI bridge to the info bar on the serve AND library "
"seams; (3) compact model display names (base name + param size + "
"ablation marker, no hf.co/ or GGUF/quant noise; cloud: vendor + "
"model + size); (4) hyperlink the v-version token to the Gitea "
"release via OSC 8 detect-then-render; (5) re-land timestamps "
"far-left in a consistent gutter per message row"
)
AFFECTED = [
"src/rune/local_tool_runtime.py",
"src/rune/core_server.py",
"src/rune/rune_operator.py",
"tui/bridge/bridge.go",
"tui/bridge/status.go",
"tui/face/infobar.go",
"tui/face/render.go",
"tui/face/timestamps.go",
"tui/face/versionlink.go",
"scripts/admit_wave_d45_live_use_batch.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-d45-live-use-batch",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-d45-live-use-batch",
"affected_surface": AFFECTED,
"evidence_refs": [
"docs/architecture/boundary-law.md (D45 all five + D44 the "
"doctrine)",
"src/rune/local_tool_runtime.py (T2 capability-selection "
"slice, _capability_selection)",
"src/rune/tool_executor.py (the 19-tool suite)",
"src/rune/core_server.py (send_task, /v1/status "
"context{used,limit})",
"tui/bridge/ (b.serve.Status → body.Context.Used/Limit → "
"setCtx → m.rt.Ctx() → ctxToken)",
"docs/architecture/boundary-law.md (D43 addendum 6, "
"timestamps far-left + version hyperlink)",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-d45-live-use-batch")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-d45-live-use-batch",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-d45-live-use-batch",
expected_new_entities=[
"src/rune/local_tool_runtime.py (the chat-path capability "
"selection fix: the mode-allowed toolset is exposed in the "
"prompt/capability blocks for local models and native "
"tool-calls for cloud; red-first live proof a CHAT-mode "
"turn sees and calls list_directory)",
"tui/bridge + tui/face (the context{used,limit} wiring on "
"the library seam: the face fetches /v1/status or its own "
"Ctx source, the info bar renders the numbers)",
"tui/face (model name compaction: base name + param size "
"+ ablation marker, path/quant noise dropped; cloud: "
"vendor + model + size)",
"tui/face (OSC 8 version hyperlink detect-then-render to "
"http://127.0.0.1:3000/conductor/rune/releases/tag/tui-v"
"<version>)",
"tui/face (timestamps far-left gutter per message row)",
"tests: red-first suites per item, -race green",
],
expected_runtime_effects=[
"a CHAT-mode chat turn exposes the read tools to the model "
"and a live list_directory call succeeds through the full "
"path",
"the info bar shows context used/limit after a turn",
"the info bar shows the compacted model name",
"the version token hyperlinks when the terminal answers "
"the OSC 8 query, plain text otherwise",
"timestamps sit in a far-left gutter per message row",
],
verification_obligations=[
"python -m pytest tests/ -count green for the touched "
"python suites; cd tui && go test ./... -race green, go "
"build + vet + gofmt clean",
"tmux PTY proofs at 110 cols with the REAL home and REAL "
"core: a real chat turn with tool visibility proof",
"deploy the binary",
],
approval_ref="user:D45 directive 2026-10-20",
contract_id="ADMIT-wave-d45-live-use-batch",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
+159
View File
@@ -0,0 +1,159 @@
"""Admission dispatch for wave d46-live-defects (D46 all four).
classify + activate + admit (the headless scaffolding entry law,
scripts/admit_wave_d45_live_use_batch.py pattern): the four live
defects from the owner's second testing round — (1) MESSAGE
DUPLICATION: a sent user message renders TWICE in the chat pane
(claim law was hardened in D43 but a path still double-renders;
red-first reproduce, find the double-apply path, pin the no-dupe
tripwire across boot-restore + live send + backfill); (2) THINKING
NOT MOVED: the thinking animation must leave the chat pane for the
bridge strip between panes and composer (full-width, above the
composer, click-to-expose, self-extinguishing) per D43 addendum 6
item 1; (3) COMPOSER SHRUNK: restore composer growth — the composer
may push BOTH panes up (D34 law: panes yield to composer growth),
verify the generous-box spec (2 rest rows, padding); (4) DISPATCH
TIMEOUT: raise the TUI + entry-proxy + core budgets to a single
coherent budget (480s) above the slowest observed 27B thinking turn
(250-310s solo), documented as D46 — approval_ref
'user:D46 directive 2026-10-20'.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"Land the D46 four live defects: (1) fix message duplication — a "
"sent user message renders twice in the chat pane; reproduce via "
"PTY, find the exact double-apply path (syncChatRows / context "
"wiring / thinking-bridge echo), fix, pin the no-dupe tripwire "
"covering boot-restore + live send + backfill paths; (2) move the "
"thinking animation out of the chat pane into the bridge strip "
"between panes and composer (full-width, above the composer, "
"click-to-expose, self-extinguishing); (3) restore composer growth "
"— the composer may push both panes up for room, generous-box spec "
"holds (2 rest rows, padding); (4) raise the dispatch timeout "
"stack (TUI client + entry proxy + core) to a single coherent 480s "
"budget above the slowest real 27B thinking turn"
)
AFFECTED = [
"tui/chatpane/",
"tui/bridge/",
"tui/face/",
"tui/lanes/",
"tui/tasks/",
"tui/cmd/rune-tui/",
"tui/runtime/lib/",
"src/go/rune-entry/internal/entry/",
"src/rune/core_server.py",
"scripts/admit_wave_d46_live_defects.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-d46-live-defects",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-d46-live-defects",
"affected_surface": AFFECTED,
"evidence_refs": [
"docs/architecture/boundary-law.md (D46 all four + D43 "
"addendum 6 item 1 the thinking bridge + D34 composer "
"yield law)",
"tui/chatpane/ (message render + claim law + "
"syncChatRows)",
"tui/bridge/ (status poll, context wiring, D45 wave in "
"flight)",
"tui/face/ (info bar, layout: panes + composer split)",
"src/go/rune-entry/internal/entry/ (entry proxy 330s "
"budget)",
"src/rune/core_server.py (core dispatch budget)",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-d46-live-defects")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-d46-live-defects",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-d46-live-defects",
expected_new_entities=[
"tui/chatpane (the no-dupe fix: exactly one render per "
"unique msg_id across boot-restore, live send, and "
"backfill paths; the double-apply path closed)",
"tui/chatpane tests (the no-dupe tripwire: frozen test "
"counting rendered bubbles vs unique msg_ids after a full "
"send→reply→poll cycle, multiple paths)",
"tui/face or tui/bridge (the thinking bridge strip: "
"full-width, between panes and composer, above the "
"composer, click-to-expose, self-extinguishing; chat pane "
"drops the thinking bubble render)",
"tui/face (composer growth restored: composer may push "
"both panes up, generous-box spec 2 rest rows + padding "
"verified)",
"tui + src/go/rune-entry + src/rune (coherent dispatch "
"budget 480s on TUI client, entry proxy, core legs; "
"documented as D46)",
"tests: red-first suites per item, -race green",
],
expected_runtime_effects=[
"a sent user message renders exactly ONCE in the chat pane "
"on every path",
"the thinking animation renders in the bridge strip above "
"the composer, not in the chat pane; click exposes the "
"thought process; it self-extinguishes",
"the composer grows and both panes yield upward; the "
"generous-box spec holds",
"a 300s+ slow-model turn no longer hits 'dispatch failed: "
"context deadline exceeded' at 330s",
"the D46 addendum: tool events render in the tasks pane's "
"work stream (never the chat transcript) with a per-task "
"tool-event history, and file paths in the tasks pane "
"carry OSC 8 file:// hyperlinks (detect-then-render)",
],
verification_obligations=[
"cd tui && go test ./... -race green, go build + vet + "
"gofmt clean; python -m pytest for touched python suites",
"PTY proofs at 110 cols with the REAL home and REAL core: "
"send a message, count bubbles (no dupe), thinking strip "
"in the bridge not the chat, composer growth pushes panes",
"slow-model simulation past 330s proving the timeout fix",
"deploy the binary via scripts/build_paired.sh, copy "
"verification/build-id/rune-tui to "
"~/.local/share/rune/bin/rune-tui",
],
approval_ref="user:D46 directive 2026-10-20",
contract_id="ADMIT-wave-d46-live-defects",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
if __name__ == "__main__":
raise SystemExit(main())
+139
View File
@@ -0,0 +1,139 @@
"""Admission dispatch for the thinking-bridge wave (D43 addendum 6 +
D44, owner directives 2026-10-20): the seven-item bridge/hud/workshop
build plus the D44 folds.
classify + activate + admit (the headless scaffolding entry law,
scripts/admit_wave_scaffold_status_line.py pattern), user-dispatch
approved via 'user:thinking-bridge directive 2026-10-20'.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"D43 addendum 6 + D44 build (owner directives 2026-10-20): "
"1) THE THINKING BRIDGE — the thinking animation moves out of the "
"chat pane into its own full-width strip between the panes and the "
"composer; clicking the strip exposes the thought process; the "
"strip exists only while thinking runs (self-extinguishing). "
"2) TIMESTAMPS far-left ALWAYS, same position regardless of sender. "
"3) COMPOSER CURSOR regression — always blinks when waiting for "
"entry. 4) TASKS PANE full workshop: active task's live coding "
"fully visible plus interactive open-task rows (expandable to the "
"D38 ledger detail). 5) SUB-AGENT OPENER in the info bar after the "
"version token, hidden at zero. 6) VERSION v-prefix + OSC 8 "
"hyperlink to the Gitea release with graceful degrade. "
"D44 folds: A) model display in the info bar (local truncated "
"name+size+style-if-applicable+ablation flag; cloud full), "
"B) queue false-positive fix (task-focus enter with nothing "
"running starts immediately), C) queued-work receipt renders in "
"the tasks pane, D) queue steer hotkey. E) the pane doctrine "
"governs all of it. Red-first each; -race; PTY proofs at 110 cols"
)
AFFECTED = [
"tui/lanes/render.go",
"tui/lanes/lanes.go",
"tui/lanes/render_test.go",
"tui/lanes/lanes_test.go",
"tui/chatpane/chatpane.go",
"tui/chatpane/chatpane_test.go",
"tui/tasks/face.go",
"tui/tasks/render.go",
"tui/tasks/workblock.go",
"tui/tasks/workshop.go",
"tui/cmd/rune-tui/main.go",
"tui/cmd/rune-tui/main_test.go",
"src/go/rune-entry/internal/entry/taskqueue.go",
"src/go/rune-entry/internal/entry/taskqueue_test.go",
"scripts/admit_wave_thinking_bridge.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-thinking-bridge",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-thinking-bridge",
"affected_surface": AFFECTED,
"evidence_refs": [
"docs/architecture/boundary-law.md (D43 addendum 6 + D44)",
"tui/lanes/render.go",
"tui/chatpane/chatpane.go",
"tui/tasks/render.go",
"tui/cmd/rune-tui/main.go",
"src/go/rune-entry/internal/entry/taskqueue.go",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-thinking-bridge")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-thinking-bridge",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-thinking-bridge",
expected_new_entities=[
"tui/tasks/workshop.go (the interactive open-task rows "
"layer: expandable ledger detail per task row)",
"scripts/admit_wave_thinking_bridge.py (this contract)",
],
expected_runtime_effects=[
"the thinking animation renders in its own full-width "
"bridge strip between the panes and the composer, only "
"while thinking runs (self-extinguishing), click-exposable",
"timestamps render far-left in a consistent gutter "
"regardless of sender",
"the composer cursor blinks whenever waiting for entry",
"the tasks pane shows the active task's live coding and "
"interactive open-task rows expanding to ledger detail",
"the info bar shows the sub-agent opener after the version "
"token (hidden at zero) and the compacted active model",
"the version renders with a v-prefix and OSC 8 hyperlink "
"to the Gitea release, degrading to plain text",
"task-focus enter with nothing running starts immediately "
"(no queue row, no queued receipt); the queued-work "
"receipt renders in the tasks pane; a hotkey steers the "
"queue (reorder/prioritise/cancel)",
],
verification_obligations=[
"go test ./... in the touched tui packages green (also "
"-race), go build ./... and go vet clean, gofmt clean",
"python-side taskqueue tests green where touched",
"tmux PTY proofs at 110 columns for each face-visible "
"item with the freshly built binary",
],
approval_ref="user:thinking-bridge directive 2026-10-20",
contract_id="ADMIT-wave-thinking-bridge",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
+7 -1
View File
@@ -79,7 +79,13 @@ MAGICS = [b"\x7fELF", b"MZ", b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
# rule 4: live store state the sweep mutates — it has landed at 102MB
# three times (commits dce911e7, fbb2e5a2, d1d55913's aftermath); it is
# runtime state, never a landed artifact, whatever its size
SWEEP_RACE_BANNED_PATHS = {"store/scaffold/current.json"}
SWEEP_RACE_BANNED_PATHS = {
"store/scaffold/current.json",
# the 83MB legacy migration snapshot — purged from history 2026-10-21
# (wave-ghost-purge; backup: backups/rune-pre-ghost-purge-2026-10-21.bundle).
# Re-entered the staged tree from wave bases 8 times; never committable.
"store/scaffold/legacy-current-migration-snapshot.json",
}
MAX_LANDING_FILE_BYTES = 20 * 1024 * 1024 # any blob over 20MB refuses
+79 -1
View File
@@ -13,6 +13,7 @@ package runecore
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -59,6 +60,11 @@ type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Thinking *string `json:"thinking,omitempty"`
// tool-loop wire fields (D45 toolset gap): the assistant's
// requested calls replay in history; a tool result rides role
// "tool" with its tool_name (the provider's parsed shape).
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
}
// ChatReply is one model answer: content, surfaced thinking (when the
@@ -67,6 +73,26 @@ type ChatReply struct {
Content string
Thinking []string
Model string
// ContextUsed/ContextLimit (D44 context readout): the tokens the
// turn actually sent vs the model's context budget. Zero when the
// client can't know (offline face, unknown model) — the bar never
// guesses.
ContextUsed int
ContextLimit int
// ToolCalls (D45 toolset gap): the model's requested tool calls on
// THIS response — empty on a final answer. Only ChatWithTools
// returns these.
ToolCalls []ToolCall
}
// ToolChatClient is the tool-loop leg of the model boundary (D45): a
// client that can carry the mode's toolset on the wire and report the
// model's requested calls back. The Ollama client implements it; a
// client that doesn't keeps the plain Chat path (honest: toolless, not
// silently pretending).
type ToolChatClient interface {
SupportsTools(ctx context.Context, model string) bool
ChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef) (ChatReply, error)
}
// ModelInfo is one model listing row.
@@ -99,6 +125,8 @@ type TurnResult struct {
AssistantText string
Model string
Thinking []string
ContextUsed int
ContextLimit int
}
// servedByLocalBinding answers whether a record's provider spelling
@@ -162,6 +190,54 @@ func (e *Engine) release(channel string) {
delete(e.inFlight, channel)
}
// runTurn drives the turn's model call: the tool loop when the client
// can carry tools AND the active mode exposes a toolset (D45 toolset
// gap — the model must SEE the mode-allowed suite and be able to ACT,
// mode-gated), the plain one-shot call otherwise.
func (e *Engine) runTurn(ctx context.Context, client ModelClient, model string, payload []ChatMessage) (ChatReply, error) {
mode := ActiveMode(e.home)
tools := ToolsetFor(mode)
if len(tools) == 0 {
return client.Chat(ctx, model, payload)
}
tc, ok := client.(ToolChatClient)
if !ok {
return client.Chat(ctx, model, payload)
}
if !tc.SupportsTools(ctx, model) {
return client.Chat(ctx, model, payload)
}
msgs := payload
var reply ChatReply
var err error
for iteration := 0; iteration < maxToolIterations; iteration++ {
reply, err = tc.ChatWithTools(ctx, model, msgs, tools)
if err != nil {
return reply, err
}
if len(reply.ToolCalls) == 0 {
return reply, nil
}
assistant := ChatMessage{Role: "assistant", Content: reply.Content, ToolCalls: reply.ToolCalls}
msgs = append(msgs, assistant)
// The dispatch gate reads the mode FRESH per dispatch (T3):
// a mode switch mid-loop is honored at the next call.
gateMode := ActiveMode(e.home)
for _, call := range reply.ToolCalls {
res := ExecuteGatedTool(e.home, gateMode, call.Name, call.Args)
raw, mErr := json.Marshal(res)
if mErr != nil {
raw = []byte(`{"success":false,"error":"result encode error"}`)
}
msgs = append(msgs, ChatMessage{Role: "tool", Content: string(raw), ToolName: call.Name})
}
}
// The loop hit its cap: one final call with the toolset withdrawn —
// the model answers in text, it never keeps demanding tools forever
// (the Python bounded-loop law).
return tc.ChatWithTools(ctx, model, msgs, nil)
}
// Turn runs one channel turn: admit, resolve the model (the record's
// provider governs the route — D42's no-local-default law), persist
// the user message, assemble THIS channel's context, call the model
@@ -226,7 +302,7 @@ func (e *Engine) Turn(ctx context.Context, req TurnRequest) (TurnResult, error)
payload = append(payload, cm)
}
reply, err := client.Chat(ctx, model, payload)
reply, err := e.runTurn(ctx, client, model, payload)
if err != nil {
// the user message stays persisted: the user DID send it (the
// Python oracle's behavior); the failure is honest, not silent
@@ -255,5 +331,7 @@ func (e *Engine) Turn(ctx context.Context, req TurnRequest) (TurnResult, error)
AssistantText: reply.Content,
Model: model,
Thinking: reply.Thinking,
ContextUsed: reply.ContextUsed,
ContextLimit: reply.ContextLimit,
}, nil
}
+125 -5
View File
@@ -45,21 +45,60 @@ type ollamaChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
Tools []ToolDef `json:"tools,omitempty"`
}
type ollamaToolCall struct {
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
type ollamaChatResponse struct {
Model string `json:"model"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
Thinking *string `json:"thinking"`
Role string `json:"role"`
Content string `json:"content"`
Thinking *string `json:"thinking"`
ToolCalls []ollamaToolCall `json:"tool_calls"`
} `json:"message"`
// token accounting (D44 context readout): prompt_eval_count =
// the history sent (the used number); eval = the model's own
// architecture context length (the limit — verified live:
// /api/show reports <arch>.context_length, but the chat response
// already carries the budget Ollama enforced for THIS call).
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
// Chat runs one /api/chat call: the full history, non-streaming, and
// the reply's thinking surfaced when the model reports it.
func (c *OllamaClient) Chat(ctx context.Context, model string, msgs []ChatMessage) (ChatReply, error) {
body, err := json.Marshal(ollamaChatRequest{Model: model, Messages: msgs, Stream: false})
return c.ChatWithTools(ctx, model, msgs, nil)
}
// SupportsTools reports whether the model advertises native tools on
// /api/show (fail-soft: an unanswerable show means false — the turn
// runs toolless rather than shipping a tools array the model can't
// parse).
func (c *OllamaClient) SupportsTools(ctx context.Context, model string) bool {
caps, err := c.showCapabilities(ctx, model)
if err != nil {
return false
}
for _, cap := range caps {
if cap == "tools" {
return true
}
}
return false
}
// ChatWithTools is the tool-loop leg (D45): the mode's toolset rides
// the request; the response's tool_calls parse into the reply.
func (c *OllamaClient) ChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef) (ChatReply, error) {
body, err := json.Marshal(ollamaChatRequest{Model: model, Messages: msgs, Stream: false, Tools: tools})
if err != nil {
return ChatReply{}, err
}
@@ -87,7 +126,25 @@ func (c *OllamaClient) Chat(ctx context.Context, model string, msgs []ChatMessag
// WINS over the inline body (Ollama 0.40.5 returns the field
// server-side for qwen3-family even without think:true).
visible, inline := splitInlineThinking(out.Message.Content)
reply := ChatReply{Content: visible, Model: out.Model}
reply := ChatReply{Content: visible, Model: out.Model,
ContextUsed: out.PromptEvalCount}
for _, tc := range out.Message.ToolCalls {
call := ToolCall{Name: tc.Function.Name}
if len(tc.Function.Arguments) > 0 {
var m map[string]any
if json.Unmarshal(tc.Function.Arguments, &m) == nil {
call.Args = m
} else {
var s string
if json.Unmarshal(tc.Function.Arguments, &s) == nil && s != "" {
_ = json.Unmarshal([]byte(s), &m)
call.Args = m
}
}
}
reply.ToolCalls = append(reply.ToolCalls, call)
}
reply.ContextLimit = c.contextLimit(ctx, model)
if out.Message.Thinking != nil && strings.TrimSpace(*out.Message.Thinking) != "" {
reply.Thinking = []string{*out.Message.Thinking}
} else if inline != "" {
@@ -102,6 +159,46 @@ type ollamaTagsResponse struct {
} `json:"models"`
}
// contextLimit asks /api/show for the model's architecture context
// length (the D44 limit). Fail-soft: 0 = unknown, the bar hides.
func (c *OllamaClient) contextLimit(ctx context.Context, model string) int {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/show", nil)
if err == nil {
// /api/show is POST with the model body
req, err = http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/show",
bytes.NewReader([]byte(`{"model":`+jsonString(model)+`}`)))
}
if err != nil {
return 0
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return 0
}
defer resp.Body.Close()
var show struct {
ModelInfo map[string]any `json:"model_info"`
}
if json.NewDecoder(resp.Body).Decode(&show) != nil {
return 0
}
// the key is <arch>.context_length — find it by suffix
for k, v := range show.ModelInfo {
if len(k) > 15 && k[len(k)-15:] == ".context_length" {
if n, ok := v.(float64); ok && n > 0 {
return int(n)
}
}
}
return 0
}
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// Tags lists the live models (GET /api/tags) — /v1/models' data.
func (c *OllamaClient) Tags(ctx context.Context) ([]ModelInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/tags", nil)
@@ -135,3 +232,26 @@ func clipStr(s string, n int) string {
}
return s[:n]
}
// showCapabilities asks /api/show for the model's capabilities list
// ("tools", "thinking", ...). Fail-soft: error → nil.
func (c *OllamaClient) showCapabilities(ctx context.Context, model string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/show",
bytes.NewReader([]byte(`{"model":`+jsonString(model)+`}`)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var show struct {
Capabilities []string `json:"capabilities"`
}
if json.NewDecoder(resp.Body).Decode(&show) != nil {
return nil, fmt.Errorf("ollama /api/show decode")
}
return show.Capabilities, nil
}
@@ -14,6 +14,7 @@ import (
"io"
"net/http"
"strconv"
"sync"
"time"
)
@@ -22,6 +23,10 @@ type Server struct {
engine *Engine
client ModelClient // /v1/models data source (Tags)
home string // provider record (active model)
mu sync.Mutex
ctx struct {
used, limit int // D44 context readout — the last turn's accounting
}
}
// NewServer builds the surface. client is the same ModelClient the
@@ -100,6 +105,9 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if rec, err := LoadProviderSelection(s.home); err == nil {
active = rec.Model
}
s.mu.Lock()
ctxUsed, ctxLimit := s.ctx.used, s.ctx.limit
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{
"identity": "Rune",
"role": "Operator",
@@ -108,6 +116,10 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"tasks_pending": 0,
"tasks_running": 0,
"tasks_completed": 0,
// D44 context readout: the last turn's real accounting
// (used = tokens sent, limit = the model's budget). Absent
// until the first turn — the bar never guesses.
"context": map[string]int{"used": ctxUsed, "limit": ctxLimit},
})
}
@@ -208,6 +220,9 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
res, err := s.engine.Turn(r.Context(), TurnRequest{
Channel: channel, UserText: userText, Model: req.Model,
})
if err == nil && res.ContextLimit > 0 {
s.setContext(res.ContextUsed, res.ContextLimit)
}
if err != nil {
var pe *ProviderError
switch {
@@ -295,3 +310,10 @@ func (s *Server) writeChunks(w http.ResponseWriter, id string, created int64, mo
}
}
}
// setContext records the last turn's context accounting (D44 readout).
func (s *Server) setContext(used, limit int) {
s.mu.Lock()
defer s.mu.Unlock()
s.ctx.used, s.ctx.limit = used, limit
}
@@ -435,3 +435,30 @@ func retryUntil(t *testing.T, ns int64, try func() bool) bool {
}
return false
}
// D45 item 2 — the context bar wiring: after ONE turn, /v1/status
// carries context{used,limit} (the numbers the TUI bridge reads via
// b.serve.Status → body.Context.Used/Limit → setCtx → m.rt.Ctx() →
// ctxToken). The shape must be present even when the accounting is
// zero — the bar hides zeros itself, but the key must never vanish.
func TestServerStatusCarriesContextReadout(t *testing.T) {
srv, ts := newTestServer(t, nil, "")
if _, err := srv.engine.Turn(t.Context(), TurnRequest{Channel: "chat", UserText: "hi"}); err != nil {
t.Fatal(err)
}
code, body := getJSON(t, ts.URL+"/v1/status")
if code != 200 {
t.Fatalf("status %d", code)
}
ctx, ok := body["context"].(map[string]any)
if !ok {
t.Fatalf("status missing context{used,limit}: %v", body)
}
if _, ok := ctx["used"]; !ok {
t.Fatalf("context missing used: %v", ctx)
}
if _, ok := ctx["limit"]; !ok {
t.Fatalf("context missing limit: %v", ctx)
}
_ = srv
}
+388
View File
@@ -0,0 +1,388 @@
package runecore
// tools.go — THE TOOLSET GAP (D45 item 5): the Go core's chat path
// surfaces the mode-allowed tool suite to the model and executes the
// model's tool calls mode-gated. Oracle: the Python operator path
// (rune_operator.py _capability_selection → local_tool_runtime.py,
// the T2/T3 wave) — the ONE frozen matrix artifact
// schemas/mode/v1/mode_matrix.json is the authority: CHAT/PLAN expose
// the read family, TASK adds the write family. The dispatch gate stays
// the security boundary: every call re-reads the active mode and a
// mode-disallowed tool executes as a typed tool error, never bytes.
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// ToolCall is one model-requested tool invocation (the Ollama
// tool_calls shape, mirrored).
type ToolCall struct {
Name string `json:"name"`
Args map[string]any `json:"arguments,omitempty"`
}
// ToolDef is one tool schema on the model wire (the OpenAI/Ollama
// function-tools envelope both providers accept).
type ToolDef struct {
Type string `json:"type"`
Function ToolFuncSpec `json:"function"`
}
// ToolFuncSpec is the function half of a ToolDef.
type ToolFuncSpec struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
// maxToolIterations bounds the model→tools→model loop (the Python
// LocalToolRuntime max_iterations law — never an unbounded loop).
const maxToolIterations = 6
// toolSpec builds a ToolDef from a JSON-schema property shorthand.
func toolSpec(name, desc string, props map[string]any, required []string) ToolDef {
return ToolDef{Type: "function", Function: ToolFuncSpec{
Name: name, Description: desc,
Parameters: map[string]any{"type": "object", "properties": props, "required": required},
}}
}
// ToolsetFor returns the mode's tool schemas — the frozen matrix's
// executor_tools ∩ the Go core's implemented ops. CHAT/PLAN: the read
// family. TASK: + write_file, edit_file, run_command. Unknown mode →
// empty (fail closed; the turn runs toolless rather than ungated).
func ToolsetFor(mode string) []ToolDef {
read := []ToolDef{
toolSpec("list_directory", "List the entries of a directory on the user's machine.",
map[string]any{"path": map[string]any{"type": "string", "description": "absolute or home-relative directory path"},
"pattern": map[string]any{"type": "string", "description": "optional glob filter"}},
[]string{"path"}),
toolSpec("read_file", "Read a file from the user's machine.",
map[string]any{"path": map[string]any{"type": "string"},
"offset": map[string]any{"type": "integer", "description": "1-based start line"},
"limit": map[string]any{"type": "integer", "description": "max lines"}},
[]string{"path"}),
toolSpec("glob_files", "Match files by glob pattern.",
map[string]any{"pattern": map[string]any{"type": "string"},
"path": map[string]any{"type": "string", "description": "base directory (default cwd)"}},
[]string{"pattern"}),
toolSpec("grep_files", "Search file contents with a regular expression.",
map[string]any{"pattern": map[string]any{"type": "string"},
"path": map[string]any{"type": "string", "description": "file or directory to search"},
"include": map[string]any{"type": "string", "description": "filename glob filter"}},
[]string{"pattern"}),
}
switch mode {
case "TASK":
return append(read,
toolSpec("write_file", "Create or overwrite a file on the user's machine.",
map[string]any{"path": map[string]any{"type": "string"},
"content": map[string]any{"type": "string"}},
[]string{"path", "content"}),
toolSpec("edit_file", "Replace the first occurrence of a search string in a file.",
map[string]any{"path": map[string]any{"type": "string"},
"search": map[string]any{"type": "string"},
"replace": map[string]any{"type": "string"}},
[]string{"path", "search", "replace"}),
toolSpec("run_command", "Run a shell command on the user's machine.",
map[string]any{"command": map[string]any{"type": "string"},
"workdir": map[string]any{"type": "string", "description": "working directory"}},
[]string{"command"}),
)
case "CHAT", "PLAN":
return read
default:
return nil
}
}
// ActiveMode resolves the gating mode (the readModeState law, the Go
// mirror of rune.mode_gate.active_mode): RUNE_MODE env wins; else
// <home>/hot/mode_state.json; absent/corrupt/unknown → CHAT.
func ActiveMode(home string) string {
if env := strings.TrimSpace(os.Getenv("RUNE_MODE")); env != "" {
switch env {
case "CHAT", "TASK", "PLAN":
return env
default:
return "CHAT"
}
}
raw, err := os.ReadFile(filepath.Join(home, "hot", "mode_state.json"))
if err == nil {
var st struct {
Mode string `json:"mode"`
}
if json.Unmarshal(raw, &st) == nil {
switch st.Mode {
case "CHAT", "TASK", "PLAN":
return st.Mode
}
}
}
return "CHAT"
}
// toolEnvelope mirrors the tool-executor's JSON envelope.
type toolEnvelope struct {
Success bool `json:"success"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// ExecuteGatedTool executes ONE model-requested tool against the mode
// gate read FRESH at dispatch (the T3 two-door law: seeing is a
// prompt, the gate is the boundary). A mode-disallowed call returns a
// typed refusal envelope — tool-error data the model can read, never
// an execution.
func ExecuteGatedTool(home, mode, name string, args map[string]any) map[string]any {
if mode == "" {
mode = ActiveMode(home)
}
allowed := ToolsetFor(mode)
var allowedNames []string
for _, t := range allowed {
allowedNames = append(allowedNames, t.Function.Name)
if t.Function.Name == name {
return executeGoTool(name, args)
}
}
sort.Strings(allowedNames)
return map[string]any{"success": false,
"error": fmt.Sprintf("tool %q is not reachable in mode %s (mode tools: %s) — rephrase or ask the owner to switch modes",
name, mode, strings.Join(allowedNames, ", "))}
}
func argStr(args map[string]any, key, def string) string {
if v, ok := args[key].(string); ok && v != "" {
return v
}
return def
}
func argInt(args map[string]any, key string) int {
switch v := args[key].(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
// executeGoTool runs the tool in-process (stdlib only — the Go core's
// zero-dep split law). Paths are home-expanded; every op is read-only
// except the TASK-only write family.
func executeGoTool(name string, args map[string]any) map[string]any {
env := func(res string, err error) map[string]any {
if err != nil {
e, _ := json.Marshal(toolEnvelope{Error: err.Error()})
return map[string]any{"success": false, "error": string(e)}
}
r, _ := json.Marshal(toolEnvelope{Success: true, Result: res})
return map[string]any{"success": true, "result": string(r)}
}
switch name {
case "list_directory":
return env(listDirectoryTool(argStr(args, "path", "."), argStr(args, "pattern", "")))
case "read_file":
return env(readFileTool(argStr(args, "path", ""), argInt(args, "offset"), argInt(args, "limit")))
case "glob_files":
return env(globFilesTool(argStr(args, "pattern", ""), argStr(args, "path", ".")))
case "grep_files":
return env(grepFilesTool(argStr(args, "pattern", ""), argStr(args, "path", "."), argStr(args, "include", "")))
case "write_file":
return env(writeFileTool(argStr(args, "path", ""), argStr(args, "content", "")))
case "edit_file":
return env(editFileTool(argStr(args, "path", ""), argStr(args, "search", ""), argStr(args, "replace", "")))
case "run_command":
return env(runCommandTool(argStr(args, "command", ""), argStr(args, "workdir", "")))
default:
return map[string]any{"success": false, "error": fmt.Sprintf("unknown tool %q", name)}
}
}
// expandHome resolves a leading ~ to the user's home (the model speaks
// user paths: "~/Desktop").
func expandHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if h, err := os.UserHomeDir(); err == nil {
return filepath.Join(h, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
}
}
return p
}
func listDirectoryTool(path, pattern string) (string, error) {
path = expandHome(path)
entries, err := os.ReadDir(path)
if err != nil {
return "", err
}
var g *regexp.Regexp
if pattern != "" {
if g, err = regexp.Compile(strings.ReplaceAll(pattern, "*", ".*")); err != nil {
return "", err
}
}
rows := make([]string, 0, len(entries))
for _, e := range entries {
if g != nil && !g.MatchString(e.Name()) {
continue
}
if e.IsDir() {
rows = append(rows, e.Name()+"/")
} else {
rows = append(rows, e.Name())
}
}
sort.Strings(rows)
return strings.Join(rows, "\n"), nil
}
func readFileTool(path string, offset, limit int) (string, error) {
raw, err := os.ReadFile(expandHome(path))
if err != nil {
return "", err
}
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if offset > 1 && offset <= len(lines) {
lines = lines[offset-1:]
} else if offset > len(lines) {
lines = nil
}
if limit > 0 && limit < len(lines) {
lines = lines[:limit]
}
if len(lines) > 2000 {
lines = lines[:2000]
}
return strings.Join(lines, "\n"), nil
}
func globFilesTool(pattern, base string) (string, error) {
base = expandHome(base)
if base == "" {
base = "."
}
matches, err := filepath.Glob(filepath.Join(base, pattern))
if err != nil {
return "", err
}
sort.Strings(matches)
return strings.Join(matches, "\n"), nil
}
func grepFilesTool(pattern, path, include string) (string, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return "", err
}
root := expandHome(path)
if root == "" {
root = "."
}
var out []string
search := func(p string) {
raw, err := os.ReadFile(p)
if err != nil {
return
}
for i, line := range strings.Split(string(raw), "\n") {
if re.MatchString(line) {
out = append(out, fmt.Sprintf("%s:%d:%s", p, i+1, strings.TrimSpace(line)))
if len(out) >= 200 {
return
}
}
}
}
if fi, err := os.Stat(root); err == nil && !fi.IsDir() {
search(root)
return strings.Join(out, "\n"), nil
}
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
switch d.Name() {
case ".git", "node_modules", "vendor":
return filepath.SkipDir
}
return nil
}
if include != "" {
if ok, _ := filepath.Match(include, d.Name()); !ok {
return nil
}
}
search(p)
if len(out) >= 200 {
return filepath.SkipAll
}
return nil
})
return strings.Join(out, "\n"), nil
}
func writeFileTool(path, content string) (string, error) {
path = expandHome(path)
if path == "" {
return "", fmt.Errorf("write_file: no path")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return "", err
}
return fmt.Sprintf("wrote %s (%d bytes)", path, len(content)), nil
}
func editFileTool(path, search, replace string) (string, error) {
path = expandHome(path)
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
s := string(raw)
if !strings.Contains(s, search) {
return "", fmt.Errorf("edit_file: search string not found in %s", path)
}
out := strings.Replace(s, search, replace, 1)
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
return "", err
}
return fmt.Sprintf("edited %s", path), nil
}
func runCommandTool(command, workdir string) (string, error) {
if strings.TrimSpace(command) == "" {
return "", fmt.Errorf("run_command: no command")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-c", command)
if workdir != "" {
cmd.Dir = expandHome(workdir)
}
out, err := cmd.CombinedOutput()
res := string(out)
if len(res) > 8000 {
res = res[:8000]
}
if err != nil {
return "", fmt.Errorf("exit error: %v: %s", err, res)
}
return res, nil
}
@@ -0,0 +1,222 @@
package runecore
// tools_test.go — THE TOOLSET GAP (D45 item 5, red-first): the Go core
// (the default live core, ensure_core.py's supervision target) must
// surface the mode-allowed tool suite to the model on the chat path
// and execute the model's tool calls mode-gated. The Python operator
// path (local_tool_runtime.py) landed this (T2/T3); the engine (D39)
// predates it and called the model exactly once with no tools — the
// owner's live defect ("I cannot directly access your desktop").
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// fakeToolModel is a ToolChatClient double: it records the toolset it
// was shown per call and answers scripted replies (a tool_call reply
// first, then a final content reply).
type fakeToolModel struct {
fakeModel
toolsets [][]ToolDef
script []ChatReply // consumed in order; last repeats
err error
payloadLog [][]ChatMessage
}
func (f *fakeToolModel) Chat(ctx context.Context, model string, msgs []ChatMessage) (ChatReply, error) {
if f.err != nil {
return ChatReply{}, f.err
}
if len(f.script) == 0 {
return ChatReply{Content: "done", Model: model}, nil
}
r := f.script[0]
if len(f.script) > 1 {
f.script = f.script[1:]
}
return r, nil
}
func (f *fakeToolModel) SupportsTools(ctx context.Context, model string) bool { return true }
func (f *fakeToolModel) ChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef) (ChatReply, error) {
f.toolsets = append(f.toolsets, append([]ToolDef(nil), tools...))
f.payloadLog = append(f.payloadLog, append([]ChatMessage(nil), msgs...))
return f.Chat(ctx, model, msgs)
}
func toolNames(tools []ToolDef) []string {
names := make([]string, 0, len(tools))
for _, t := range tools {
names = append(names, t.Function.Name)
}
return names
}
func has(names []string, want string) bool {
for _, n := range names {
if n == want {
return true
}
}
return false
}
func TestChatTurnExposesReadToolsInChatMode(t *testing.T) {
// D45: a CHAT-mode turn's model must SEE the read tools
// (list_directory/read_file at minimum) and never the write family.
t.Setenv("RUNE_MODE", "CHAT")
fm := &fakeToolModel{}
fm.replies = map[string]string{"": "done"}
fm.script = []ChatReply{{Content: "here is your listing", Model: "m"}}
e, _ := newTestEngine(t, fm)
res, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "list my desktop"})
if err != nil {
t.Fatalf("turn: %v", err)
}
if len(fm.toolsets) == 0 {
t.Fatal("model was called with no toolset — the toolset gap (D45)")
}
names := toolNames(fm.toolsets[0])
if !has(names, "list_directory") || !has(names, "read_file") {
t.Fatalf("read tools not exposed in CHAT mode: %v", names)
}
if has(names, "write_file") || has(names, "edit_file") || has(names, "run_command") {
t.Fatalf("write tools exposed in CHAT mode: %v", names)
}
_ = res
}
func TestChatModeRefusesModeDisallowedToolCall(t *testing.T) {
// The dispatch gate stays the security boundary (T3): a CHAT-mode
// model call for write_file executes as a typed tool error, never
// bytes on disk.
t.Setenv("RUNE_MODE", "CHAT")
dir := t.TempDir()
fm := &fakeToolModel{}
fm.script = []ChatReply{
{Content: "", ToolCalls: []ToolCall{{Name: "write_file", Args: map[string]any{
"path": filepath.Join(dir, "x.txt"), "content": "no"}}}},
{Content: "ok, stopped", Model: "m"},
}
e, _ := newTestEngine(t, fm)
res, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "write a file"})
if err != nil {
t.Fatalf("turn: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "x.txt")); !os.IsNotExist(err) {
t.Fatal("write_file EXECUTED in CHAT mode — the mode gate failed")
}
if len(fm.payloadLog) < 2 {
t.Fatalf("expected a tool-result round trip, got %d calls", len(fm.payloadLog))
}
toolMsgs := []ChatMessage{}
for _, m := range fm.payloadLog[1] {
if m.Role == "tool" {
toolMsgs = append(toolMsgs, m)
}
}
if len(toolMsgs) == 0 {
t.Fatal("no tool result message fed back to the model")
}
if !strings.Contains(toolMsgs[0].Content, "not reachable in mode CHAT") {
t.Fatalf("tool result should carry the typed mode refusal, got: %s", toolMsgs[0].Content)
}
if res.AssistantText != "ok, stopped" {
t.Fatalf("final answer = %q", res.AssistantText)
}
}
func TestTaskModeExposesWrites(t *testing.T) {
// The mode-gated matrix: TASK exposes the write family (and the
// model's write actually lands — Rune can act).
t.Setenv("RUNE_MODE", "TASK")
dir := t.TempDir()
target := filepath.Join(dir, "made.txt")
fm := &fakeToolModel{}
fm.script = []ChatReply{
{Content: "", ToolCalls: []ToolCall{{Name: "write_file", Args: map[string]any{
"path": target, "content": "hello"}}}},
{Content: "wrote it", Model: "m"},
}
e, _ := newTestEngine(t, fm)
res, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "write the file"})
if err != nil {
t.Fatalf("turn: %v", err)
}
names := toolNames(fm.toolsets[0])
if !has(names, "write_file") || !has(names, "run_command") {
t.Fatalf("write tools not exposed in TASK mode: %v", names)
}
raw, err := os.ReadFile(target)
if err != nil {
t.Fatalf("write_file did not land: %v", err)
}
if string(raw) != "hello" {
t.Fatalf("file content = %q", raw)
}
if res.AssistantText != "wrote it" {
t.Fatalf("final answer = %q", res.AssistantText)
}
}
func TestListDirectoryToolCallFullLoop(t *testing.T) {
// The D45 red-first: a CHAT-mode turn calls list_directory and the
// REAL listing reaches the model as a tool result.
t.Setenv("RUNE_MODE", "CHAT")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "desktop.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
fm := &fakeToolModel{}
fm.script = []ChatReply{
{Content: "", ToolCalls: []ToolCall{{Name: "list_directory", Args: map[string]any{"path": dir}}}},
{Content: "found desktop.txt", Model: "m"},
}
e, _ := newTestEngine(t, fm)
res, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "list it"})
if err != nil {
t.Fatalf("turn: %v", err)
}
var seen bool
for _, m := range fm.payloadLog[1] {
if m.Role == "tool" && strings.Contains(m.Content, "desktop.txt") {
seen = true
}
}
if !seen {
var dump, _ = json.Marshal(fm.payloadLog[1])
t.Fatalf("the listing never reached the model: %s", dump)
}
if res.AssistantText != "found desktop.txt" {
t.Fatalf("final answer = %q", res.AssistantText)
}
}
func TestToolLoopBounded(t *testing.T) {
// The loop is bounded (the Python max_iterations law): a model that
// always demands a tool call stops at the cap and answers.
t.Setenv("RUNE_MODE", "CHAT")
fm := &fakeToolModel{}
loop := ChatReply{Content: "", ToolCalls: []ToolCall{{Name: "list_directory", Args: map[string]any{"path": "."}}}}
fm.script = []ChatReply{loop, loop, loop, loop, loop, loop, {Content: "gave up", Model: "m"}}
e, _ := newTestEngine(t, fm)
res, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "loop forever"})
if err != nil {
t.Fatalf("turn: %v", err)
}
if maxToolIterations <= 1 || maxToolIterations > 16 {
t.Fatalf("maxToolIterations = %d", maxToolIterations)
}
if got := len(fm.payloadLog); got != maxToolIterations+1 {
t.Fatalf("loop ran %d calls, want bounded %d (+1 capped final)", got, maxToolIterations)
}
if res.AssistantText != "gave up" {
t.Fatalf("final answer = %q", res.AssistantText)
}
}
+7 -4
View File
@@ -115,10 +115,12 @@ environment:
`
// proxyClientBudget is the proxy client's timeout budget (chat-live P1
// F3): >=330s — the core operator's 300s escalation ladder plus
// headroom — mirroring the TUI completion client's budget
// (tui/runtime/lib.CompletionBudget). Pinned by surface_test.go.
const proxyClientBudget = 330 * time.Second
// F3, D46 item 4): 480s — ONE coherent budget with the TUI completion
// client (tui/runtime/lib.CompletionBudget). The D46 diagnosis: the
// 27B thinking models run 250-310s SOLO and the stacked queue/memory/
// tool latency on top busted the old 330s; 480s sits above the worst
// observed real turn with headroom. Pinned by surface_test.go.
const proxyClientBudget = 480 * time.Second
type frontDoor struct {
core string
@@ -273,6 +275,7 @@ func (f *frontDoor) mux() *http.ServeMux {
// admission law (POST enqueue; taskqueue.go).
mux.HandleFunc("/v1/task-queue", f.handleTaskQueue)
mux.HandleFunc("/v1/task-queue/enqueue", f.handleTaskQueueEnqueue)
mux.HandleFunc("/v1/task-queue/steer", f.handleTaskQueueSteer)
mux.HandleFunc("/v1/health", f.handleHealth)
// K23: inspect-before-connect integration settings (task 5.2) —
// capability listing, exact-scope approval, MCP discovery feeding
@@ -82,10 +82,20 @@ const fakeConvCheckpoint = `{"messages": [
{"channel_id": "other", "content": "elsewhere", "msg_id": "msg_aa03", "role": "user", "schema_version": 1, "task_id": null, "token_estimate": 0, "ts": "2026-10-16T00:00:03+00:00"}
], "schema_version": 1}`
const fakeTaskCheckpoint = `{"active": {"chat": "task_2"}, "schema_version": 1, "tasks": [
{"assigned_model": null, "channel_id": "chat", "created_at": "2026-10-16T00:00:01+00:00", "outcome_ref": "resp:1", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "completed", "task_id": "task_1", "title": "Done thing", "updated_at": "2026-10-16T00:00:02+00:00"},
{"assigned_model": "qwen3:14b", "channel_id": "chat", "created_at": "2026-10-16T00:00:03+00:00", "outcome_ref": "", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "running", "task_id": "task_2", "title": "Live thing", "updated_at": "2026-10-16T00:00:04+00:00"}
// fakeTaskCheckpoint returns the two-row fixture with FRESH stamps for
// the running row (D44-B: occupancy is freshness-gated — a stale row
// does not hold the lane, so the busy-lane fixtures must stamp live).
// The completed row stamps one second earlier so the newest-first
// ordering stays deterministic.
func fakeTaskCheckpoint() string {
now := time.Now().UTC()
newer := now.Format("2006-01-02T15:04:05.999999+00:00")
older := now.Add(-time.Second).Format("2006-01-02T15:04:05.999999+00:00")
return `{"active": {"chat": "task_2"}, "schema_version": 1, "tasks": [
{"assigned_model": null, "channel_id": "chat", "created_at": "` + older + `", "outcome_ref": "resp:1", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "completed", "task_id": "task_1", "title": "Done thing", "updated_at": "` + older + `"},
{"assigned_model": "qwen3:14b", "channel_id": "chat", "created_at": "` + newer + `", "outcome_ref": "", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "running", "task_id": "task_2", "title": "Live thing", "updated_at": "` + newer + `"}
]}`
}
func journalLine(id, eventType, ts string) string {
return fmt.Sprintf(`{"actor_id": "", "authorization": "", "device_id": "", "event_id": %q, "event_type": %q, "parent_id": null, "partition_key": "rune_operator", "payload_hash": "sha256:ab", "schema_version": 1, "timestamp": %q}`,
@@ -196,7 +206,7 @@ func TestConversationCoreDownNoCheckpointKeepsHonestError(t *testing.T) {
func TestTasksServesCanonicalCheckpoint(t *testing.T) {
ts := newTestSurface(t, nil)
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
rec := httptest.NewRecorder()
ts.front.mux().ServeHTTP(rec, httptest.NewRequest("GET", "/v1/tasks", nil))
@@ -238,7 +248,7 @@ func TestTasksServesCanonicalCheckpoint(t *testing.T) {
func TestTasksStatusFilterAndLimit(t *testing.T) {
ts := newTestSurface(t, nil)
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
rec := httptest.NewRecorder()
ts.front.mux().ServeHTTP(rec, httptest.NewRequest("GET", "/v1/tasks?status=open", nil))
@@ -418,7 +428,7 @@ func TestHealthReportsLiveSubsystems(t *testing.T) {
fmt.Fprint(w, `{"identity": "rune", "role": "operator"}`)
})
ts.plant(t, "conv", fakeConvCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
ts.plant(t, "journal", journalLine("evt_01", "user.message", "2026-10-16T00:00:01+00:00")+"\n")
ts.plant(t, "health", fakeHealthRegistry)
ts.plant(t, "work/batches.json", fakeBatches)
@@ -498,14 +508,15 @@ func TestHealthUnavailableIsExplicitNeverZeros(t *testing.T) {
}
// TestProxyClientBudgetCoversTheOperatorLadder is the proxy leg of the
// timeout-budget fixture (chat-live P1 F3): the front door's proxy
// client carries >=330s — the core's 300s operator ladder plus
// headroom, mirroring tui/runtime/lib.CompletionBudget — so a
// completion is never early-cut at the old 130s mark. Structural on
// purpose: the bound is the frozen contract, not a wall-clock wait.
// timeout-budget fixture (chat-live P1 F3, D46 item 4): the front
// door's proxy client carries >=480s — the coherent D46 budget the
// TUI's lib.CompletionBudget also holds — so a 27B thinking turn
// (250-310s solo) plus stacked queue/memory/tool latency is never
// early-cut at the old 330s mark. Structural on purpose: the bound is
// the frozen contract, not a wall-clock wait.
func TestProxyClientBudgetCoversTheOperatorLadder(t *testing.T) {
if proxyClientBudget < 330*time.Second {
t.Fatalf("proxyClientBudget = %v, want >= 330s (300s ladder + headroom)", proxyClientBudget)
if proxyClientBudget < 480*time.Second {
t.Fatalf("proxyClientBudget = %v, want >= 480s (the D46 coherent budget: 27B worst turn + stacked latency)", proxyClientBudget)
}
}
+144 -2
View File
@@ -112,14 +112,27 @@ func queuePendingView(store *queueStore) []map[string]any {
}
// occupyingTaskRow is the task lane's occupying turn: a running row,
// else a row paused on approval (an open turn holds the lane either
// way — a consent pause is not a free lane). Deterministic: lowest
// else a row paused on approval — an open turn holds the lane either
// way (a consent pause is not a free lane). Deterministic: lowest
// task_id first among candidates, mirroring runningTaskID.
//
// D44-B (the false-positive fix): an occupying row must be FRESH — a
// live turn re-stamps its updated_at on every tool event, a live
// approval pause is minutes old. The owner's live checkpoint carried
// 45 awaiting_approval rows and one running row from WEEKS back
// (unreconciled history), which read as a permanently busy lane: every
// task-enter queued with nothing actually running. A row whose stamp
// is older than queueOccupyFresh no longer occupies; an unparseable or
// missing stamp still occupies (fail-closed — never start over a
// possibly-live turn).
func occupyingTaskRow(rows map[string]map[string]any) map[string]any {
for _, status := range []string{"running", "awaiting_approval"} {
var ids []string
for id, row := range rows {
if s, _ := row["status"].(string); s == status {
if !occupyingRowFresh(row) {
continue
}
ids = append(ids, id)
}
}
@@ -132,6 +145,32 @@ func occupyingTaskRow(rows map[string]map[string]any) map[string]any {
return nil
}
// queueOccupyFresh bounds how stale an occupying row may be. Generous
// to any live turn (activity re-stamps within minutes) and to a live
// approval pause, tight enough that the stale history never holds the
// lane hostage again.
const queueOccupyFresh = 30 * time.Minute
// queueNow is the occupancy clock (test seam).
var queueNow = time.Now
// occupyingRowFresh reports whether one row's updated_at names live
// work. A stamp that cannot be parsed FAILS CLOSED (occupies).
func occupyingRowFresh(row map[string]any) bool {
raw, _ := row["updated_at"].(string)
if raw == "" {
return true
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, raw); err == nil {
return queueNow().Sub(t) <= queueOccupyFresh
}
}
// the checkpoint's python-side stamp (…+00:00 with microseconds)
// parses as RFC3339Nano; anything else is unreadable — fail closed
return true
}
// queueRunningView projects the occupying row as the surface's running
// entry (nil row = nil view).
func queueRunningView(row map[string]any) map[string]any {
@@ -277,6 +316,109 @@ func (f *frontDoor) handleTaskQueueEnqueue(w http.ResponseWriter, r *http.Reques
})
}
// steerRequest is one queue-steer mutation (D44-D: the hotkey's
// reorder/prioritise/cancel over the pending queue).
type steerRequest struct {
Action string `json:"action"` // "reorder" | "cancel"
EntryID string `json:"entry_id"`
Delta int `json:"delta"` // reorder: -1 up (prioritise), +1 down
}
// handleTaskQueueSteer is POST /v1/task-queue/steer: the D44 steer
// law — reorder (delta -1 up / +1 down, clamped to the neighbours) or
// cancel (remove) a PENDING entry. The running turn is never touched
// and discharge keeps its FIFO head rule over the steered order.
func (f *frontDoor) handleTaskQueueSteer(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/task-queue/steer" {
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
return
}
if r.Method != http.MethodPost {
errorEnvelope(w, http.StatusMethodNotAllowed, "bad_method", "task-queue/steer is POST")
return
}
var req steerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorEnvelope(w, http.StatusBadRequest, "bad_request", "unreadable steer: "+err.Error())
return
}
if req.EntryID == "" {
errorEnvelope(w, http.StatusBadRequest, "bad_request", "steer needs an entry_id")
return
}
if req.Action != "reorder" && req.Action != "cancel" {
errorEnvelope(w, http.StatusBadRequest, "bad_request", "steer action must be reorder or cancel")
return
}
p := f.surface()
f.queueMu.Lock()
store, ok := readQueueStore(p.queue)
if !ok {
f.queueMu.Unlock()
errorEnvelope(w, http.StatusConflict, "state_unavailable",
"task-queue store unreadable — refusing to steer over unreadable pending work: "+p.queue)
return
}
idx := -1
for i, e := range store.Entries {
if e.EntryID == req.EntryID {
idx = i
break
}
}
if idx < 0 {
f.queueMu.Unlock()
errorEnvelope(w, http.StatusNotFound, "unknown_entry", "no pending entry "+req.EntryID)
return
}
switch req.Action {
case "cancel":
store.Entries = append(store.Entries[:idx], store.Entries[idx+1:]...)
if !writeQueueStore(p.queue, store) {
f.queueMu.Unlock()
errorEnvelope(w, http.StatusInternalServerError, "state_unwritable",
"the task-queue store refused the write")
return
}
f.queueMu.Unlock()
appendQueueEnvelope(p.journal, "task.queue.cancel", req.EntryID)
case "reorder":
j := idx + req.Delta
if j < 0 {
j = 0
}
if j >= len(store.Entries) {
j = len(store.Entries) - 1
}
if j != idx {
e := store.Entries[idx]
store.Entries = append(store.Entries[:idx], store.Entries[idx+1:]...)
store.Entries = append(store.Entries[:j], append([]queueEntry{e}, store.Entries[j:]...)...)
if !writeQueueStore(p.queue, store) {
f.queueMu.Unlock()
errorEnvelope(w, http.StatusInternalServerError, "state_unwritable",
"the task-queue store refused the write")
return
}
appendQueueEnvelope(p.journal, "task.queue.reorder", req.EntryID)
}
f.queueMu.Unlock()
}
f.queueMu.Lock()
store, ok = readQueueStore(p.queue)
f.queueMu.Unlock()
pending := []map[string]any{}
if ok {
pending = queuePendingView(store)
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"action": req.Action,
"pending": pending,
})
}
// delegateTaskCreate runs the NORMAL task-create path for a free lane:
// POST /v1/chat/completions with channel "task" (the core's send_task
// — the same dispatch the composer's enter takes). Runs OUTSIDE the
@@ -60,17 +60,127 @@ const completedOnlyCheckpoint = `{"active": {}, "schema_version": 1, "tasks": [
{"assigned_model": null, "channel_id": "task", "created_at": "2026-10-16T00:00:01+00:00", "outcome_ref": "resp:1", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "completed", "task_id": "task_1", "title": "Done thing", "updated_at": "2026-10-16T00:00:02+00:00"}
]}`
const awaitingApprovalCheckpoint = `{"active": {}, "schema_version": 1, "tasks": [
{"assigned_model": "qwen3:14b", "channel_id": "task", "created_at": "2026-10-16T00:00:03+00:00", "outcome_ref": "", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "awaiting_approval", "task_id": "task_3", "title": "Paused thing", "updated_at": "2026-10-16T00:00:04+00:00"}
// awaitingApprovalCheckpoint returns the approval-pause fixture with a
// FRESH stamp (D44-B: occupancy is freshness-gated).
func awaitingApprovalCheckpoint() string {
now := time.Now().UTC().Format("2006-01-02T15:04:05.999999+00:00")
return `{"active": {}, "schema_version": 1, "tasks": [
{"assigned_model": "qwen3:14b", "channel_id": "task", "created_at": "` + now + `", "outcome_ref": "", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "awaiting_approval", "task_id": "task_3", "title": "Paused thing", "updated_at": "` + now + `"}
]}`
}
// ── the admission law ────────────────────────────────────────────────────────
// TestTaskQueueEnqueueStaleRowsDelegate is the D44-B red: the owner's
// live checkpoint carried 45 awaiting_approval rows and one running row
// from WEEKS back (unreconciled history) — the busy-check read idle as
// busy and every task-enter queued with nothing running. Stale rows do
// not occupy; a task-enter with nothing LIVE running starts immediately.
func TestTaskQueueEnqueueStaleRowsDelegate(t *testing.T) {
var coreHits int
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {
coreHits++
fmt.Fprint(w, `{"id": "chatcmpl-x", "choices": [{"message": {"role": "assistant", "content": "started"}}]}`)
})
stale := time.Now().Add(-72 * time.Hour).UTC().Format("2006-01-02T15:04:05.999999+00:00")
checkpoint := `{"active": {}, "schema_version": 1, "tasks": [
{"assigned_model": "qwen3:14b", "channel_id": "task", "created_at": "` + stale + `", "outcome_ref": "", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "running", "task_id": "task_8fd54d", "title": "Stuck running", "updated_at": "` + stale + `"},
{"assigned_model": "qwen3:14b", "channel_id": "task", "created_at": "` + stale + `", "outcome_ref": "", "parent_task_id": null, "route_ref": "", "schema_version": 1, "status": "awaiting_approval", "task_id": "task_9df12250", "title": "Zombie pause", "updated_at": "` + stale + `"}
]}`
ts.plant(t, "tasks", checkpoint)
rec, out := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "build the scheduler", "origin": "task_enter"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if out["action"] != "started" || out["delegated"] != true {
t.Fatalf("stale rows must not occupy the lane: action=%v delegated=%v body=%s",
out["action"], out["delegated"], rec.Body.String())
}
if coreHits != 1 {
t.Fatalf("core hits = %d, want 1 (immediate dispatch)", coreHits)
}
if _, err := os.Stat(ts.canvas["queue"]); !os.IsNotExist(err) {
t.Fatal("an idle-lane delegation must not write the queue store")
}
}
// TestTaskQueueEnqueueLiveApprovalStillHolds: a FRESH awaiting_approval
// row is a live consent pause — the lane stays busy (the consent-pause
// law survives the D44-B freshness gate).
func TestTaskQueueEnqueueLiveApprovalStillHolds(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("a live approval pause must not let a task-enter touch the core")
})
ts.plant(t, "tasks", awaitingApprovalCheckpoint())
rec, out := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "build the scheduler"}`)
if rec.Code != http.StatusOK || out["action"] != "queued" {
t.Fatalf("live pause must queue: status %d action=%v", rec.Code, out["action"])
}
}
// TestTaskQueueSteerReorderAndCancel pins the D44-D steer surface:
// reorder moves a pending entry within the FIFO (clamped), cancel
// removes it, both journaled, both answered with the new pending view.
func TestTaskQueueSteerReorderAndCancel(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("steer never touches the core")
})
ts.plant(t, "tasks", fakeTaskCheckpoint()) // live running row: enqueues queue
rec, out := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "first"}`)
if rec.Code != http.StatusOK {
t.Fatalf("enqueue 1: %d", rec.Code)
}
id1, _ := out["entry_id"].(string)
rec, out = ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "second"}`)
if rec.Code != http.StatusOK {
t.Fatalf("enqueue 2: %d", rec.Code)
}
id2, _ := out["entry_id"].(string)
// prioritise the second entry (delta -1)
rec, out = ts.postJSON(t, "/v1/task-queue/steer",
`{"action": "reorder", "entry_id": "`+id2+`", "delta": -1}`)
if rec.Code != http.StatusOK {
t.Fatalf("steer reorder: %d body %s", rec.Code, rec.Body.String())
}
pending, _ := out["pending"].([]any)
if len(pending) != 2 {
t.Fatalf("pending rows = %d, want 2", len(pending))
}
first, _ := pending[0].(map[string]any)
if first["entry_id"] != id2 {
t.Fatalf("reorder did not prioritise: first = %v", first["entry_id"])
}
// cancel it again
rec, out = ts.postJSON(t, "/v1/task-queue/steer",
`{"action": "cancel", "entry_id": "`+id2+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("steer cancel: %d", rec.Code)
}
pending, _ = out["pending"].([]any)
if len(pending) != 1 {
t.Fatalf("after cancel pending = %d, want 1", len(pending))
}
first, _ = pending[0].(map[string]any)
if first["entry_id"] != id1 {
t.Fatalf("cancel removed the wrong entry: %v", first["entry_id"])
}
// unknown entry refuses honestly
rec, _ = ts.postJSON(t, "/v1/task-queue/steer", `{"action": "cancel", "entry_id": "q_nope"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown entry must 404, got %d", rec.Code)
}
}
func TestTaskQueueEnqueueWhenBusyQueues(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("busy lane must not touch the core")
})
ts.plant(t, "tasks", fakeTaskCheckpoint) // task_2 running
ts.plant(t, "tasks", fakeTaskCheckpoint()) // task_2 running
rec, out := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "build the scheduler"}`)
if rec.Code != http.StatusOK {
@@ -182,7 +292,7 @@ func TestTaskQueueAwaitingApprovalOccupiesLane(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("a consent-paused turn still holds the lane")
})
ts.plant(t, "tasks", awaitingApprovalCheckpoint)
ts.plant(t, "tasks", awaitingApprovalCheckpoint())
rec, out := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "next thing"}`)
if rec.Code != http.StatusOK || out["action"] != "queued" {
@@ -194,7 +304,7 @@ func TestTaskQueueAwaitingApprovalOccupiesLane(t *testing.T) {
func TestTaskQueueGetListsOldestFirstWithRunningEntry(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {})
ts.plant(t, "tasks", fakeTaskCheckpoint) // task_2 running
ts.plant(t, "tasks", fakeTaskCheckpoint()) // task_2 running
ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "first demand"}`)
time.Sleep(2 * time.Millisecond) // distinct stamps
ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "second demand"}`)
@@ -262,7 +372,7 @@ func TestTaskQueueGetEmptyAndAbsentCheckpoint(t *testing.T) {
func TestTaskQueueCorruptStoreRefuses(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {})
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
ts.plant(t, "queue", `{not json`)
rec, view := ts.getJSONMap(t, "/v1/task-queue")
@@ -285,7 +395,7 @@ func TestTaskQueueCorruptStoreRefuses(t *testing.T) {
func TestTaskQueueMethodAndBodyValidation(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {})
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
rec, _ := ts.postJSON(t, "/v1/task-queue", `{}`)
if rec.Code != http.StatusMethodNotAllowed {
@@ -308,7 +418,7 @@ func TestTaskQueueMethodAndBodyValidation(t *testing.T) {
func TestTaskQueueEnqueueJournalsEnvelope(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {})
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "audited demand"}`)
lines, _ := tailJournalLines(ts.canvas["journal"], 0)
@@ -331,7 +441,7 @@ func TestTaskQueueEnqueueJournalsEnvelope(t *testing.T) {
func TestTaskQueueConcurrentEnqueuesAllLand(t *testing.T) {
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {})
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
const n = 8
var wg sync.WaitGroup
@@ -486,7 +596,7 @@ func TestTaskQueueDischargeOnCompletionFIFO(t *testing.T) {
done := ts.front.startQueueDischarge(ctx)
defer func() { stop(); <-done }()
ts.plant(t, "tasks", fakeTaskCheckpoint) // task_2 running: lane busy
ts.plant(t, "tasks", fakeTaskCheckpoint()) // task_2 running: lane busy
ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "first demand"}`)
time.Sleep(2 * time.Millisecond)
ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "second demand"}`)
@@ -551,7 +661,7 @@ func TestTaskQueueDischargeFailureRequeuesAtFront(t *testing.T) {
done := ts.front.startQueueDischarge(ctx)
defer func() { stop(); <-done }()
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "first demand"}`)
ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "second demand"}`)
@@ -634,7 +744,7 @@ func TestTaskQueueDischargeRace(t *testing.T) {
done := ts.front.startQueueDischarge(ctx)
defer func() { stop(); <-done }()
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
stopBusy := make(chan struct{})
var writer sync.WaitGroup
writer.Add(1)
@@ -648,7 +758,7 @@ func TestTaskQueueDischargeRace(t *testing.T) {
default:
}
if business {
ts.plant(t, "tasks", fakeTaskCheckpoint)
ts.plant(t, "tasks", fakeTaskCheckpoint())
} else {
ts.plant(t, "tasks", completedOnlyCheckpoint)
}
+35
View File
@@ -224,6 +224,41 @@ func (l *Ledger) ListOpen() ([]OpenTask, error) {
return out, nil
}
// Detail is one task's full ledger record — the D38 markdown's data:
// the brief, the append-only log, and the timestamps. This is the
// interactive open-task rows' expansion payload (boundary-law §D43
// addendum 6 item 4: the OPEN TASKS section becomes an interactive
// surface).
type Detail struct {
TaskID string
Title string
Initiated string
Completed string
Status string
Brief string
Log []string
}
// DetailOf reads one task's markdown file and returns its record. The
// path is the ledger file ListOpen named (OpenTask.File). A file that
// fails to parse is an error the caller names honestly — never a
// fabricated empty record.
func DetailOf(path string) (Detail, error) {
doc, err := parseFile(path)
if err != nil {
return Detail{}, err
}
return Detail{
TaskID: doc.TaskID,
Title: doc.Title,
Initiated: doc.Initiated,
Completed: doc.Completed,
Status: doc.Status,
Brief: doc.Brief,
Log: append([]string(nil), doc.Log...),
}, nil
}
// Sync reconciles the ledger with the real task rows: a row without a
// ledger file gets one (at its real creation time); an open ledger
// file whose row went terminal is crossed off and retired (at the
+1 -1
View File
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
import json
from pathlib import Path
__version__ = "0.321.0"
__version__ = "0.325.1"
# One build identity (handoff 2026-09-10 §4): the semantic version above is
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
+1 -1
View File
@@ -233,7 +233,7 @@ def main() -> int:
)
check(
"Rune minor version advertises provisioning and restore verification",
'__version__ = "0.321.0"' in rune_version,
'__version__ = "0.325.1"' in rune_version,
failures,
)
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
+2 -2
View File
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
This is not an interface. This is how I see, speak, and interact with the world.
Version: v0.321.0
Version: v0.325.1
Features: 41 (24 original + 17 new native commands)
Usage:
@@ -2035,7 +2035,7 @@ class RuneApp(App):
# Show version info
self.chat_channel.add_message(
"system",
f"[dim]Rune v0.321.0 | Textual TUI | {len(loaded)} native modules[/dim]",
f"[dim]Rune v0.325.1 | Textual TUI | {len(loaded)} native modules[/dim]",
)
# Auto-show night mode suggestion if evening/night hours
@@ -0,0 +1,34 @@
# Contract ADMIT-wave-d45-live-use-batch
**Phase:** closed · **Mode:** n/a · **Depth:** n/a
## What was built
Land the D45 live-use critique batch: (1) fix the toolset gap — the TUI chat path (core_server send_task → rune_operator → local_tool_runtime) must surface the mode-allowed tool suite to the model, CHAT mode read tools visible and callable live, TASK mode writes exposed; (2) wire /v1/status context{used,limit} through the TUI bridge to the info bar on the serve AND library seams; (3) compact model display names (base name + param size + ablation marker, no hf.co/ or GGUF/quant noise; cloud: vendor + model + size); (4) hyperlink the v-version token to the Gitea release via OSC 8 detect-then-render; (5) re-land timestamps far-left in a consistent gutter per message row
## What it connects to
- `scripts/admit_wave_d45_live_use_batch.py`
- `src/rune/core_server.py`
- `src/rune/local_tool_runtime.py`
- `src/rune/rune_operator.py`
- `tui/bridge/bridge.go`
- `tui/bridge/status.go`
- `tui/face/infobar.go`
- `tui/face/render.go`
- `tui/face/timestamps.go`
- `tui/face/versionlink.go`
## How to update those connections
- Open a NEW construction contract (scaffolding is absolute) scoped to the same surface.
- Evidence trail: 5 checkpoint(s) recorded on the contract entity.
- Quality at close: {"candidate_corrections": [{"entity": "tests", "note": "genuinely unexpected new surface \u2014 evidence required before store update", "status": "CANDIDATE"}], "captured_at": "2026-10-20T21:21:21Z", "contract": "ADMIT-wave-d45-live-use-batch", "correctly_predicted": [], "evidence_sources": ["go test"], "expected_new_entities": ["src/rune/local_tool_runtime.py (the chat-path capability selection fix: the mode-allowed toolset is exposed in the prompt/capability blocks for local models and native tool-calls for cloud; red-first live proof a CHAT-mode turn sees and calls list_directory)", "tests: red-first suites per item, -race green", "tui/bridge + tui/face (the context{used,limit} wiring on the library seam: the face fetches /v1/status or its own Ctx source, the info bar renders the numbers)", "tui/face (OSC 8 version hyperlink detect-then-render to http://127.0.0.1:3000/conductor/rune/releases/tag/tui-v<version>)", "tui/face (model name compaction: base name + param size + ablation marker, path/quant noise dropped; cloud: vendor + model + size)", "tui/face (timestamps far-left gutter per message row)"], "expected_new_missing": ["src/rune/local_tool_runtime.py (the chat-path capability selection fix: the mode-allowed toolset is exposed in the prompt/capability blocks for local models and native tool-calls for cloud; red-first live proof a CHAT-mode turn sees and calls list_directory)", "tests: red-first suites per item, -race green", "tui/bridge + tui/face (the context{used,limit} wiring on the library seam: the face fetches /v1/status or its own Ctx source, the info bar renders the numbers)", "tui/face (OSC 8 version hyperlink detect-then-render to http://127.0.0.1:3000/conductor/rune/releases/tag/tui-v<version>)", "tui/face (model name compaction: base name + param size + ablation marker, path/quant noise dropped; cloud: vendor + model + size)", "tui/face (timestamps far-left gutter per message row)"], "expected_new_observed": [], "expected_runtime_effects": ["a CHAT-mode chat turn exposes the read tools to the model and a live list_directory call succeeds through the full path", "the info bar shows context used/limit after a turn", "the info bar shows the compacted model name", "the version token hyperlinks when the terminal answers the OSC 8 query, plain text otherwise", "timestamps sit in a far-left gutter per message row"], "expected_runtime_missing": ["a CHAT-mode chat turn exposes the read tools to the model and a live list_directory call succeeds through the full path", "the info bar shows context used/limit after a turn", "the info bar shows the compacted model name", "the version token hyperlinks when the terminal answers the OSC 8 query, plain text otherwise", "timestamps sit in a far-left gutter per message row"], "expected_runtime_observed": [], "genuinely_unexpected_new": ["tests"], "missed": ["tests"], "needless": [], "observed": ["tests"], "observed_existing": [], "observed_new": ["tests"], "predicted_but_unaffected": [], "predicted_existing_affected": []}.
## Evidence
- predicted-frontier
- user:D45 directive 2026-10-20
- builder:wave-d45-live-use-batch
- race-green; live tool proofs
- closure-record
@@ -0,0 +1,30 @@
# Contract ADMIT-wave-scaffold-status-line
**Phase:** closed · **Mode:** n/a · **Depth:** n/a
## What was built
Build the scaffolding status line: the tasks pane shows 'scaffolding · <animation> · <stage>' while a governed construction contract is active — the stage from the real contract state (admit/build/verify/land via the authority lock and the contract entity's checkpoints), the hammer/ladder glyph walk in the dots-walk rhythm, self-extinguishing on close, arbitration-resting, its own layer per the Layer Law
## What it connects to
- `scripts/admit_wave_scaffold_status_line.py`
- `tui/cmd/rune-tui/main.go`
- `tui/tasks/face.go`
- `tui/tasks/render.go`
- `tui/tasks/scaffoldline.go`
- `tui/tasks/scaffoldline_test.go`
## How to update those connections
- Open a NEW construction contract (scaffolding is absolute) scoped to the same surface.
- Evidence trail: 5 checkpoint(s) recorded on the contract entity.
- Quality at close: {"candidate_corrections": [{"entity": "tests", "note": "genuinely unexpected new surface \u2014 evidence required before store update", "status": "CANDIDATE"}], "captured_at": "2026-10-20T10:15:49Z", "contract": "ADMIT-wave-scaffold-status-line", "correctly_predicted": [], "evidence_sources": ["go test"], "expected_new_entities": ["tui/cmd/rune-tui/main.go (shell wiring: the shared tick advances the walk ~500ms; ARBITRATION \u2014 while scaffolding animates the other animations rest)", "tui/tasks/scaffoldline.go (the SCAFFOLDING STATUS LINE layer: ReadScaffoldStatus from the durable authority lock + contract checkpoints, the stage map admit/build/verify/land, the hammer/ladder dots-walk, ScaffoldLine render)", "tui/tasks/scaffoldline_test.go (the layer's own frozen suite: stage mapping, walk rhythm, extinguish-on-close, layer isolation)"], "expected_new_missing": ["tui/cmd/rune-tui/main.go (shell wiring: the shared tick advances the walk ~500ms; ARBITRATION \u2014 while scaffolding animates the other animations rest)", "tui/tasks/scaffoldline.go (the SCAFFOLDING STATUS LINE layer: ReadScaffoldStatus from the durable authority lock + contract checkpoints, the stage map admit/build/verify/land, the hammer/ladder dots-walk, ScaffoldLine render)", "tui/tasks/scaffoldline_test.go (the layer's own frozen suite: stage mapping, walk rhythm, extinguish-on-close, layer isolation)"], "expected_new_observed": [], "expected_runtime_effects": ["the line self-extinguishes when the contract closes (the lock is cleared)", "the stage is the contract's real phase from the store \u2014 never a guess", "while the line animates the other animations rest (arbitration, canon P3)", "with a real active contract the tasks pane renders 'scaffolding \u00b7 <glyph walk> \u00b7 <stage>' above the work blocks, separate from the per-wave sub-agent rows"], "expected_runtime_missing": ["the line self-extinguishes when the contract closes (the lock is cleared)", "the stage is the contract's real phase from the store \u2014 never a guess", "while the line animates the other animations rest (arbitration, canon P3)", "with a real active contract the tasks pane renders 'scaffolding \u00b7 <glyph walk> \u00b7 <stage>' above the work blocks, separate from the per-wave sub-agent rows"], "expected_runtime_observed": [], "genuinely_unexpected_new": ["tests"], "missed": ["tests"], "needless": [], "observed": ["tests"], "observed_existing": [], "observed_new": ["tests"], "predicted_but_unaffected": [], "predicted_existing_affected": []}.
## Evidence
- predicted-frontier
- user:workshop-pane directive 2026-10-19
- builder:wave-scaffold-status-line
- PTY raw-byte proof; race-green
- closure-record
@@ -0,0 +1,39 @@
# Contract ADMIT-wave-thinking-bridge
**Phase:** closed · **Mode:** n/a · **Depth:** n/a
## What was built
D43 addendum 6 + D44 build (owner directives 2026-10-20): 1) THE THINKING BRIDGE — the thinking animation moves out of the chat pane into its own full-width strip between the panes and the composer; clicking the strip exposes the thought process; the strip exists only while thinking runs (self-extinguishing). 2) TIMESTAMPS far-left ALWAYS, same position regardless of sender. 3) COMPOSER CURSOR regression — always blinks when waiting for entry. 4) TASKS PANE full workshop: active task's live coding fully visible plus interactive open-task rows (expandable to the D38 ledger detail). 5) SUB-AGENT OPENER in the info bar after the version token, hidden at zero. 6) VERSION v-prefix + OSC 8 hyperlink to the Gitea release with graceful degrade. D44 folds: A) model display in the info bar (local truncated name+size+style-if-applicable+ablation flag; cloud full), B) queue false-positive fix (task-focus enter with nothing running starts immediately), C) queued-work receipt renders in the tasks pane, D) queue steer hotkey. E) the pane doctrine governs all of it. Red-first each; -race; PTY proofs at 110 cols
## What it connects to
- `scripts/admit_wave_thinking_bridge.py`
- `src/go/rune-entry/internal/entry/taskqueue.go`
- `src/go/rune-entry/internal/entry/taskqueue_test.go`
- `tui/chatpane/chatpane.go`
- `tui/chatpane/chatpane_test.go`
- `tui/cmd/rune-tui/main.go`
- `tui/cmd/rune-tui/main_test.go`
- `tui/lanes/lanes.go`
- `tui/lanes/lanes_test.go`
- `tui/lanes/render.go`
- `tui/lanes/render_test.go`
- `tui/tasks/face.go`
- `tui/tasks/render.go`
- `tui/tasks/workblock.go`
- `tui/tasks/workshop.go`
## How to update those connections
- Open a NEW construction contract (scaffolding is absolute) scoped to the same surface.
- Evidence trail: 5 checkpoint(s) recorded on the contract entity.
- Quality at close: {"candidate_corrections": [{"entity": "tests", "note": "genuinely unexpected new surface \u2014 evidence required before store update", "status": "CANDIDATE"}], "captured_at": "2026-10-20T13:46:44Z", "contract": "ADMIT-wave-thinking-bridge", "correctly_predicted": [], "evidence_sources": ["go test"], "expected_new_entities": ["scripts/admit_wave_thinking_bridge.py (this contract)", "tui/tasks/workshop.go (the interactive open-task rows layer: expandable ledger detail per task row)"], "expected_new_missing": ["scripts/admit_wave_thinking_bridge.py (this contract)", "tui/tasks/workshop.go (the interactive open-task rows layer: expandable ledger detail per task row)"], "expected_new_observed": [], "expected_runtime_effects": ["task-focus enter with nothing running starts immediately (no queue row, no queued receipt); the queued-work receipt renders in the tasks pane; a hotkey steers the queue (reorder/prioritise/cancel)", "the composer cursor blinks whenever waiting for entry", "the info bar shows the sub-agent opener after the version token (hidden at zero) and the compacted active model", "the tasks pane shows the active task's live coding and interactive open-task rows expanding to ledger detail", "the thinking animation renders in its own full-width bridge strip between the panes and the composer, only while thinking runs (self-extinguishing), click-exposable", "the version renders with a v-prefix and OSC 8 hyperlink to the Gitea release, degrading to plain text", "timestamps render far-left in a consistent gutter regardless of sender"], "expected_runtime_missing": ["task-focus enter with nothing running starts immediately (no queue row, no queued receipt); the queued-work receipt renders in the tasks pane; a hotkey steers the queue (reorder/prioritise/cancel)", "the composer cursor blinks whenever waiting for entry", "the info bar shows the sub-agent opener after the version token (hidden at zero) and the compacted active model", "the tasks pane shows the active task's live coding and interactive open-task rows expanding to ledger detail", "the thinking animation renders in its own full-width bridge strip between the panes and the composer, only while thinking runs (self-extinguishing), click-exposable", "the version renders with a v-prefix and OSC 8 hyperlink to the Gitea release, degrading to plain text", "timestamps render far-left in a consistent gutter regardless of sender"], "expected_runtime_observed": [], "genuinely_unexpected_new": ["tests"], "missed": ["tests"], "needless": [], "observed": ["tests"], "observed_existing": [], "observed_new": ["tests"], "predicted_but_unaffected": [], "predicted_existing_affected": []}.
## Evidence
- predicted-frontier
- user:thinking-bridge directive 2026-10-20
- builder:wave-thinking-bridge
- tests green
- closure-record
@@ -0,0 +1,274 @@
{
"checkpoints": [
{
"at": "2026-10-20T14:16:31Z",
"evidence": "predicted-frontier",
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
"phase": "draft"
},
{
"at": "2026-10-20T14:16:52Z",
"evidence": "user:D45 directive 2026-10-20",
"note": "",
"phase": "approved"
},
{
"at": "2026-10-20T14:17:13Z",
"evidence": "builder:wave-d45-live-use-batch",
"note": "",
"phase": "building"
},
{
"at": "2026-10-20T21:21:00Z",
"evidence": "race-green; live tool proofs",
"note": "",
"phase": "verifying"
},
{
"at": "2026-10-20T21:21:21Z",
"evidence": "closure-record",
"note": "predicted-vs-observed captured",
"phase": "closed"
}
],
"evidence_refs": [
"contract-created:ADMIT-wave-d45-live-use-batch"
],
"ext": {
"activation": {
"activated": true,
"builder": "wave-d45-live-use-batch",
"evidence": [
"affected surface known: ['src/rune/local_tool_runtime.py', 'src/rune/core_server.py']",
"change scoped to an existing project/target",
"target project: rune"
],
"lifecycle": "proportional",
"mode": "EXISTING_PROJECT_CHANGE",
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
"project_authority": "rune_internal",
"target_project": "rune"
},
"affected_paths": [
"scripts/admit_wave_d45_live_use_batch.py",
"src/rune/core_server.py",
"src/rune/local_tool_runtime.py",
"src/rune/rune_operator.py",
"tui/bridge/bridge.go",
"tui/bridge/status.go",
"tui/face/infobar.go",
"tui/face/render.go",
"tui/face/timestamps.go",
"tui/face/versionlink.go"
],
"affected_regions": [],
"affected_ui_surfaces": [],
"contract_quality": {
"candidate_corrections": [
{
"entity": "tests",
"note": "genuinely unexpected new surface \u2014 evidence required before store update",
"status": "CANDIDATE"
}
],
"captured_at": "2026-10-20T21:21:21Z",
"contract": "ADMIT-wave-d45-live-use-batch",
"correctly_predicted": [],
"evidence_sources": [
"go test"
],
"expected_new_entities": [
"src/rune/local_tool_runtime.py (the chat-path capability selection fix: the mode-allowed toolset is exposed in the prompt/capability blocks for local models and native tool-calls for cloud; red-first live proof a CHAT-mode turn sees and calls list_directory)",
"tests: red-first suites per item, -race green",
"tui/bridge + tui/face (the context{used,limit} wiring on the library seam: the face fetches /v1/status or its own Ctx source, the info bar renders the numbers)",
"tui/face (OSC 8 version hyperlink detect-then-render to http://127.0.0.1:3000/conductor/rune/releases/tag/tui-v<version>)",
"tui/face (model name compaction: base name + param size + ablation marker, path/quant noise dropped; cloud: vendor + model + size)",
"tui/face (timestamps far-left gutter per message row)"
],
"expected_new_missing": [
"src/rune/local_tool_runtime.py (the chat-path capability selection fix: the mode-allowed toolset is exposed in the prompt/capability blocks for local models and native tool-calls for cloud; red-first live proof a CHAT-mode turn sees and calls list_directory)",
"tests: red-first suites per item, -race green",
"tui/bridge + tui/face (the context{used,limit} wiring on the library seam: the face fetches /v1/status or its own Ctx source, the info bar renders the numbers)",
"tui/face (OSC 8 version hyperlink detect-then-render to http://127.0.0.1:3000/conductor/rune/releases/tag/tui-v<version>)",
"tui/face (model name compaction: base name + param size + ablation marker, path/quant noise dropped; cloud: vendor + model + size)",
"tui/face (timestamps far-left gutter per message row)"
],
"expected_new_observed": [],
"expected_runtime_effects": [
"a CHAT-mode chat turn exposes the read tools to the model and a live list_directory call succeeds through the full path",
"the info bar shows context used/limit after a turn",
"the info bar shows the compacted model name",
"the version token hyperlinks when the terminal answers the OSC 8 query, plain text otherwise",
"timestamps sit in a far-left gutter per message row"
],
"expected_runtime_missing": [
"a CHAT-mode chat turn exposes the read tools to the model and a live list_directory call succeeds through the full path",
"the info bar shows context used/limit after a turn",
"the info bar shows the compacted model name",
"the version token hyperlinks when the terminal answers the OSC 8 query, plain text otherwise",
"timestamps sit in a far-left gutter per message row"
],
"expected_runtime_observed": [],
"genuinely_unexpected_new": [
"tests"
],
"missed": [
"tests"
],
"needless": [],
"observed": [
"tests"
],
"observed_existing": [],
"observed_new": [
"tests"
],
"predicted_but_unaffected": [],
"predicted_existing_affected": []
},
"expected_runtime_effects": [
"a CHAT-mode chat turn exposes the read tools to the model and a live list_directory call succeeds through the full path",
"the info bar shows context used/limit after a turn",
"the info bar shows the compacted model name",
"the version token hyperlinks when the terminal answers the OSC 8 query, plain text otherwise",
"timestamps sit in a far-left gutter per message row"
],
"predicted_frontier": {
"change_kind": "implementation_change",
"contextual_not_obligations": [],
"contracts": [],
"level0_direct": [],
"level1_obligations": [],
"level2_verification": [],
"max_level": 2,
"traversal_scope": "spine-structural-only"
},
"scope_provenance": {
"caller_added": [],
"generated_from_affected_surface": [
"scripts/admit_wave_d45_live_use_batch.py",
"src/rune/core_server.py",
"src/rune/local_tool_runtime.py",
"src/rune/rune_operator.py",
"tui/bridge/bridge.go",
"tui/bridge/status.go",
"tui/face/infobar.go",
"tui/face/render.go",
"tui/face/timestamps.go",
"tui/face/versionlink.go"
]
},
"task_ref": "wave-d45-live-use-batch",
"unresolved_surfaces": [
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/admit_wave_d45_live_use_batch.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/rune/core_server.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/rune/local_tool_runtime.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/rune/rune_operator.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/bridge/bridge.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/bridge/status.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/face/infobar.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/face/render.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/face/timestamps.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/face/versionlink.go"
}
],
"workflow_config_surfaces": []
},
"id": "contract:ADMIT-wave-d45-live-use-batch",
"identities": {
"acceptance": [
"bounded repair verified: Land the D45 live-use critique batch: (1) fix the toolset gap \u2014 the TUI chat path (core_server send_task \u2192 rune_operator"
],
"affected_entities": [],
"expected_consumers": [],
"expected_new_entities": [
"src/rune/local_tool_runtime.py (the chat-path capability selection fix: the mode-allowed toolset is exposed in the prompt/capability blocks for local models and native tool-calls for cloud; red-first live proof a CHAT-mode turn sees and calls list_directory)",
"tests: red-first suites per item, -race green",
"tui/bridge + tui/face (the context{used,limit} wiring on the library seam: the face fetches /v1/status or its own Ctx source, the info bar renders the numbers)",
"tui/face (OSC 8 version hyperlink detect-then-render to http://127.0.0.1:3000/conductor/rune/releases/tag/tui-v<version>)",
"tui/face (model name compaction: base name + param size + ablation marker, path/quant noise dropped; cloud: vendor + model + size)",
"tui/face (timestamps far-left gutter per message row)"
],
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['src/rune/local_tool_runtime.py', 'src/rune/core_server.py']",
"migration_obligations": [],
"need": "Land the D45 live-use critique batch: (1) fix the toolset gap \u2014 the TUI chat path (core_server send_task \u2192 rune_operator \u2192 local_tool_runtime) must surface the mode-allowed tool suite to the model, CHAT mode read tools visible and callable live, TASK mode writes exposed; (2) wire /v1/status context{used,limit} through the TUI bridge to the info bar on the serve AND library seams; (3) compact model display names (base name + param size + ablation marker, no hf.co/ or GGUF/quant noise; cloud: vendor + model + size); (4) hyperlink the v-version token to the Gitea release via OSC 8 detect-then-render; (5) re-land timestamps far-left in a consistent gutter per message row",
"next_action": "observe real outcome; capture lessons",
"non_goals": [],
"origin": "worker",
"regression_obligations": [],
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
"research_refs": [
"docs/architecture/boundary-law.md (D43 addendum 6, timestamps far-left + version hyperlink)",
"docs/architecture/boundary-law.md (D45 all five + D44 the doctrine)",
"src/rune/core_server.py (send_task, /v1/status context{used,limit})",
"src/rune/local_tool_runtime.py (T2 capability-selection slice, _capability_selection)",
"src/rune/tool_executor.py (the 19-tool suite)",
"tui/bridge/ (b.serve.Status \u2192 body.Context.Used/Limit \u2192 setCtx \u2192 m.rt.Ctx() \u2192 ctxToken)"
],
"resolution": "",
"rollback_ref": "git revert",
"scope": [
"scripts/admit_wave_d45_live_use_batch.py",
"src/rune/core_server.py",
"src/rune/local_tool_runtime.py",
"src/rune/rune_operator.py",
"tui/bridge/bridge.go",
"tui/bridge/status.go",
"tui/face/infobar.go",
"tui/face/render.go",
"tui/face/timestamps.go",
"tui/face/versionlink.go"
],
"toolchain": {},
"verification_obligations": [
"deploy the binary",
"python -m pytest tests/ -count green for the touched python suites; cd tui && go test ./... -race green, go build + vet + gofmt clean",
"tmux PTY proofs at 110 cols with the REAL home and REAL core: a real chat turn with tool visibility proof"
]
},
"kind": "construction_contract",
"maturity": "DECLARED",
"observed_at": "2026-10-20T21:21:21Z",
"phase": "closed",
"status": "closed",
"tags": []
}
@@ -0,0 +1,255 @@
{
"checkpoints": [
{
"at": "2026-10-20T18:24:24Z",
"evidence": "predicted-frontier",
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
"phase": "draft"
},
{
"at": "2026-10-20T18:24:50Z",
"evidence": "user:D46 directive 2026-10-20",
"note": "",
"phase": "approved"
},
{
"at": "2026-10-20T18:25:14Z",
"evidence": "builder:wave-d46-live-defects",
"note": "",
"phase": "building"
},
{
"at": "2026-10-20T23:47:41Z",
"evidence": "race-green; deployed paired build",
"note": "",
"phase": "verifying"
},
{
"at": "2026-10-20T23:48:02Z",
"evidence": "closure-record",
"note": "predicted-vs-observed captured",
"phase": "closed"
}
],
"evidence_refs": [
"contract-created:ADMIT-wave-d46-live-defects"
],
"ext": {
"activation": {
"activated": true,
"builder": "wave-d46-live-defects",
"evidence": [
"affected surface known: ['tui/chatpane/', 'tui/bridge/']",
"smallness markers: ['patch']"
],
"lifecycle": "bounded",
"mode": "LOCAL_CHANGE",
"note": "bounded governance via existing authority \u2014 no Project Scaffold bootstrap",
"project_authority": "rune_internal",
"target_project": "rune"
},
"affected_paths": [
"scripts/admit_wave_d46_live_defects.py",
"src/go/rune-entry/internal/entry/",
"src/rune/core_server.py",
"tui/bridge/",
"tui/chatpane/",
"tui/composer/",
"tui/face/",
"tui/lanes/"
],
"affected_regions": [],
"affected_ui_surfaces": [],
"contract_quality": {
"candidate_corrections": [
{
"entity": "tests",
"note": "genuinely unexpected new surface \u2014 evidence required before store update",
"status": "CANDIDATE"
}
],
"captured_at": "2026-10-20T23:48:02Z",
"contract": "ADMIT-wave-d46-live-defects",
"correctly_predicted": [],
"evidence_sources": [
"go test"
],
"expected_new_entities": [
"tests: red-first suites per item, -race green",
"tui + src/go/rune-entry + src/rune (coherent dispatch budget 480s on TUI client, entry proxy, core legs; documented as D46)",
"tui/chatpane (the no-dupe fix: exactly one render per unique msg_id across boot-restore, live send, and backfill paths; the double-apply path closed)",
"tui/chatpane tests (the no-dupe tripwire: frozen test counting rendered bubbles vs unique msg_ids after a full send\u2192reply\u2192poll cycle, multiple paths)",
"tui/face (composer growth restored: composer may push both panes up, generous-box spec 2 rest rows + padding verified)",
"tui/face or tui/bridge (the thinking bridge strip: full-width, between panes and composer, above the composer, click-to-expose, self-extinguishing; chat pane drops the thinking bubble render)"
],
"expected_new_missing": [
"tests: red-first suites per item, -race green",
"tui + src/go/rune-entry + src/rune (coherent dispatch budget 480s on TUI client, entry proxy, core legs; documented as D46)",
"tui/chatpane (the no-dupe fix: exactly one render per unique msg_id across boot-restore, live send, and backfill paths; the double-apply path closed)",
"tui/chatpane tests (the no-dupe tripwire: frozen test counting rendered bubbles vs unique msg_ids after a full send\u2192reply\u2192poll cycle, multiple paths)",
"tui/face (composer growth restored: composer may push both panes up, generous-box spec 2 rest rows + padding verified)",
"tui/face or tui/bridge (the thinking bridge strip: full-width, between panes and composer, above the composer, click-to-expose, self-extinguishing; chat pane drops the thinking bubble render)"
],
"expected_new_observed": [],
"expected_runtime_effects": [
"a 300s+ slow-model turn no longer hits 'dispatch failed: context deadline exceeded' at 330s",
"a sent user message renders exactly ONCE in the chat pane on every path",
"the composer grows and both panes yield upward; the generous-box spec holds",
"the thinking animation renders in the bridge strip above the composer, not in the chat pane; click exposes the thought process; it self-extinguishes"
],
"expected_runtime_missing": [
"a 300s+ slow-model turn no longer hits 'dispatch failed: context deadline exceeded' at 330s",
"a sent user message renders exactly ONCE in the chat pane on every path",
"the composer grows and both panes yield upward; the generous-box spec holds",
"the thinking animation renders in the bridge strip above the composer, not in the chat pane; click exposes the thought process; it self-extinguishes"
],
"expected_runtime_observed": [],
"genuinely_unexpected_new": [
"tests"
],
"missed": [
"tests"
],
"needless": [],
"observed": [
"tests"
],
"observed_existing": [],
"observed_new": [
"tests"
],
"predicted_but_unaffected": [],
"predicted_existing_affected": []
},
"expected_runtime_effects": [
"a 300s+ slow-model turn no longer hits 'dispatch failed: context deadline exceeded' at 330s",
"a sent user message renders exactly ONCE in the chat pane on every path",
"the composer grows and both panes yield upward; the generous-box spec holds",
"the thinking animation renders in the bridge strip above the composer, not in the chat pane; click exposes the thought process; it self-extinguishes"
],
"predicted_frontier": {
"change_kind": "implementation_change",
"contextual_not_obligations": [],
"contracts": [],
"level0_direct": [],
"level1_obligations": [],
"level2_verification": [],
"max_level": 2,
"traversal_scope": "spine-structural-only"
},
"scope_provenance": {
"caller_added": [],
"generated_from_affected_surface": [
"scripts/admit_wave_d46_live_defects.py",
"src/go/rune-entry/internal/entry/",
"src/rune/core_server.py",
"tui/bridge/",
"tui/chatpane/",
"tui/composer/",
"tui/face/",
"tui/lanes/"
]
},
"task_ref": "wave-d46-live-defects",
"unresolved_surfaces": [
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/admit_wave_d46_live_defects.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/rune-entry/internal/entry/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/rune/core_server.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/bridge/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/chatpane/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/composer/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/face/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/lanes/"
}
],
"workflow_config_surfaces": []
},
"id": "contract:ADMIT-wave-d46-live-defects",
"identities": {
"acceptance": [
"bounded repair verified: Land the D46 four live defects: (1) fix message duplication \u2014 a sent user message renders twice in the chat pane; reprod"
],
"affected_entities": [],
"expected_consumers": [],
"expected_new_entities": [
"tests: red-first suites per item, -race green",
"tui + src/go/rune-entry + src/rune (coherent dispatch budget 480s on TUI client, entry proxy, core legs; documented as D46)",
"tui/chatpane (the no-dupe fix: exactly one render per unique msg_id across boot-restore, live send, and backfill paths; the double-apply path closed)",
"tui/chatpane tests (the no-dupe tripwire: frozen test counting rendered bubbles vs unique msg_ids after a full send\u2192reply\u2192poll cycle, multiple paths)",
"tui/face (composer growth restored: composer may push both panes up, generous-box spec 2 rest rows + padding verified)",
"tui/face or tui/bridge (the thinking bridge strip: full-width, between panes and composer, above the composer, click-to-expose, self-extinguishing; chat pane drops the thinking bubble render)"
],
"gap_evidence": "smallness markers: ['patch']; affected surface known: ['tui/chatpane/', 'tui/bridge/']",
"migration_obligations": [],
"need": "Land the D46 four live defects: (1) fix message duplication \u2014 a sent user message renders twice in the chat pane; reproduce via PTY, find the exact double-apply path (syncChatRows / context wiring / thinking-bridge echo), fix, pin the no-dupe tripwire covering boot-restore + live send + backfill paths; (2) move the thinking animation out of the chat pane into the bridge strip between panes and composer (full-width, above the composer, click-to-expose, self-extinguishing); (3) restore composer growth \u2014 the composer may push both panes up for room, generous-box spec holds (2 rest rows, padding); (4) raise the dispatch timeout stack (TUI client + entry proxy + core) to a single coherent 480s budget above the slowest real 27B thinking turn",
"next_action": "observe real outcome; capture lessons",
"non_goals": [],
"origin": "worker",
"regression_obligations": [],
"requirement": "headless admission (classified: LOCAL_CHANGE)",
"research_refs": [
"docs/architecture/boundary-law.md (D46 all four + D43 addendum 6 item 1 the thinking bridge + D34 composer yield law)",
"src/go/rune-entry/internal/entry/ (entry proxy 330s budget)",
"src/rune/core_server.py (core dispatch budget)",
"tui/bridge/ (status poll, context wiring, D45 wave in flight)",
"tui/chatpane/ (message render + claim law + syncChatRows)",
"tui/face/ (info bar, layout: panes + composer split)"
],
"resolution": "",
"rollback_ref": "git revert",
"scope": [
"scripts/admit_wave_d46_live_defects.py",
"src/go/rune-entry/internal/entry/",
"src/rune/core_server.py",
"tui/bridge/",
"tui/chatpane/",
"tui/composer/",
"tui/face/",
"tui/lanes/"
],
"toolchain": {},
"verification_obligations": [
"PTY proofs at 110 cols with the REAL home and REAL core: send a message, count bubbles (no dupe), thinking strip in the bridge not the chat, composer growth pushes panes",
"cd tui && go test ./... -race green, go build + vet + gofmt clean; python -m pytest for touched python suites",
"deploy the binary via scripts/build_paired.sh, copy verification/build-id/rune-tui to ~/.local/share/rune/bin/rune-tui",
"slow-model simulation past 330s proving the timeout fix"
]
},
"kind": "construction_contract",
"maturity": "DECLARED",
"observed_at": "2026-10-20T23:48:02Z",
"phase": "closed",
"status": "closed",
"tags": []
}
@@ -0,0 +1,308 @@
{
"checkpoints": [
{
"at": "2026-10-20T12:45:46Z",
"evidence": "predicted-frontier",
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
"phase": "draft"
},
{
"at": "2026-10-20T12:46:09Z",
"evidence": "user:thinking-bridge directive 2026-10-20",
"note": "",
"phase": "approved"
},
{
"at": "2026-10-20T12:46:32Z",
"evidence": "builder:wave-thinking-bridge",
"note": "",
"phase": "building"
},
{
"at": "2026-10-20T13:46:15Z",
"evidence": "tests green",
"note": "",
"phase": "verifying"
},
{
"at": "2026-10-20T13:46:44Z",
"evidence": "closure-record",
"note": "predicted-vs-observed captured",
"phase": "closed"
}
],
"evidence_refs": [
"contract-created:ADMIT-wave-thinking-bridge"
],
"ext": {
"activation": {
"activated": true,
"builder": "wave-thinking-bridge",
"evidence": [
"affected surface known: ['tui/lanes/render.go', 'tui/lanes/lanes.go']",
"change scoped to an existing project/target",
"target project: rune"
],
"lifecycle": "proportional",
"mode": "EXISTING_PROJECT_CHANGE",
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
"project_authority": "rune_internal",
"target_project": "rune"
},
"affected_paths": [
"scripts/admit_wave_thinking_bridge.py",
"src/go/rune-entry/internal/entry/taskqueue.go",
"src/go/rune-entry/internal/entry/taskqueue_test.go",
"tui/chatpane/chatpane.go",
"tui/chatpane/chatpane_test.go",
"tui/cmd/rune-tui/main.go",
"tui/cmd/rune-tui/main_test.go",
"tui/lanes/lanes.go",
"tui/lanes/lanes_test.go",
"tui/lanes/render.go",
"tui/lanes/render_test.go",
"tui/tasks/face.go",
"tui/tasks/render.go",
"tui/tasks/workblock.go",
"tui/tasks/workshop.go"
],
"affected_regions": [],
"affected_ui_surfaces": [],
"contract_quality": {
"candidate_corrections": [
{
"entity": "tests",
"note": "genuinely unexpected new surface \u2014 evidence required before store update",
"status": "CANDIDATE"
}
],
"captured_at": "2026-10-20T13:46:44Z",
"contract": "ADMIT-wave-thinking-bridge",
"correctly_predicted": [],
"evidence_sources": [
"go test"
],
"expected_new_entities": [
"scripts/admit_wave_thinking_bridge.py (this contract)",
"tui/tasks/workshop.go (the interactive open-task rows layer: expandable ledger detail per task row)"
],
"expected_new_missing": [
"scripts/admit_wave_thinking_bridge.py (this contract)",
"tui/tasks/workshop.go (the interactive open-task rows layer: expandable ledger detail per task row)"
],
"expected_new_observed": [],
"expected_runtime_effects": [
"task-focus enter with nothing running starts immediately (no queue row, no queued receipt); the queued-work receipt renders in the tasks pane; a hotkey steers the queue (reorder/prioritise/cancel)",
"the composer cursor blinks whenever waiting for entry",
"the info bar shows the sub-agent opener after the version token (hidden at zero) and the compacted active model",
"the tasks pane shows the active task's live coding and interactive open-task rows expanding to ledger detail",
"the thinking animation renders in its own full-width bridge strip between the panes and the composer, only while thinking runs (self-extinguishing), click-exposable",
"the version renders with a v-prefix and OSC 8 hyperlink to the Gitea release, degrading to plain text",
"timestamps render far-left in a consistent gutter regardless of sender"
],
"expected_runtime_missing": [
"task-focus enter with nothing running starts immediately (no queue row, no queued receipt); the queued-work receipt renders in the tasks pane; a hotkey steers the queue (reorder/prioritise/cancel)",
"the composer cursor blinks whenever waiting for entry",
"the info bar shows the sub-agent opener after the version token (hidden at zero) and the compacted active model",
"the tasks pane shows the active task's live coding and interactive open-task rows expanding to ledger detail",
"the thinking animation renders in its own full-width bridge strip between the panes and the composer, only while thinking runs (self-extinguishing), click-exposable",
"the version renders with a v-prefix and OSC 8 hyperlink to the Gitea release, degrading to plain text",
"timestamps render far-left in a consistent gutter regardless of sender"
],
"expected_runtime_observed": [],
"genuinely_unexpected_new": [
"tests"
],
"missed": [
"tests"
],
"needless": [],
"observed": [
"tests"
],
"observed_existing": [],
"observed_new": [
"tests"
],
"predicted_but_unaffected": [],
"predicted_existing_affected": []
},
"expected_runtime_effects": [
"task-focus enter with nothing running starts immediately (no queue row, no queued receipt); the queued-work receipt renders in the tasks pane; a hotkey steers the queue (reorder/prioritise/cancel)",
"the composer cursor blinks whenever waiting for entry",
"the info bar shows the sub-agent opener after the version token (hidden at zero) and the compacted active model",
"the tasks pane shows the active task's live coding and interactive open-task rows expanding to ledger detail",
"the thinking animation renders in its own full-width bridge strip between the panes and the composer, only while thinking runs (self-extinguishing), click-exposable",
"the version renders with a v-prefix and OSC 8 hyperlink to the Gitea release, degrading to plain text",
"timestamps render far-left in a consistent gutter regardless of sender"
],
"predicted_frontier": {
"change_kind": "implementation_change",
"contextual_not_obligations": [],
"contracts": [],
"level0_direct": [],
"level1_obligations": [],
"level2_verification": [],
"max_level": 2,
"traversal_scope": "spine-structural-only"
},
"scope_provenance": {
"caller_added": [],
"generated_from_affected_surface": [
"scripts/admit_wave_thinking_bridge.py",
"src/go/rune-entry/internal/entry/taskqueue.go",
"src/go/rune-entry/internal/entry/taskqueue_test.go",
"tui/chatpane/chatpane.go",
"tui/chatpane/chatpane_test.go",
"tui/cmd/rune-tui/main.go",
"tui/cmd/rune-tui/main_test.go",
"tui/lanes/lanes.go",
"tui/lanes/lanes_test.go",
"tui/lanes/render.go",
"tui/lanes/render_test.go",
"tui/tasks/face.go",
"tui/tasks/render.go",
"tui/tasks/workblock.go",
"tui/tasks/workshop.go"
]
},
"task_ref": "wave-thinking-bridge",
"unresolved_surfaces": [
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/admit_wave_thinking_bridge.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/rune-entry/internal/entry/taskqueue.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/rune-entry/internal/entry/taskqueue_test.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/chatpane/chatpane.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/chatpane/chatpane_test.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/cmd/rune-tui/main.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/cmd/rune-tui/main_test.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/lanes/lanes.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/lanes/lanes_test.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/lanes/render.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/lanes/render_test.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/tasks/face.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/tasks/render.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/tasks/workblock.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/tasks/workshop.go"
}
],
"workflow_config_surfaces": []
},
"id": "contract:ADMIT-wave-thinking-bridge",
"identities": {
"acceptance": [
"bounded repair verified: D43 addendum 6 + D44 build (owner directives 2026-10-20): 1) THE THINKING BRIDGE \u2014 the thinking animation moves out of t"
],
"affected_entities": [],
"expected_consumers": [],
"expected_new_entities": [
"scripts/admit_wave_thinking_bridge.py (this contract)",
"tui/tasks/workshop.go (the interactive open-task rows layer: expandable ledger detail per task row)"
],
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['tui/lanes/render.go', 'tui/lanes/lanes.go']",
"migration_obligations": [],
"need": "D43 addendum 6 + D44 build (owner directives 2026-10-20): 1) THE THINKING BRIDGE \u2014 the thinking animation moves out of the chat pane into its own full-width strip between the panes and the composer; clicking the strip exposes the thought process; the strip exists only while thinking runs (self-extinguishing). 2) TIMESTAMPS far-left ALWAYS, same position regardless of sender. 3) COMPOSER CURSOR regression \u2014 always blinks when waiting for entry. 4) TASKS PANE full workshop: active task's live coding fully visible plus interactive open-task rows (expandable to the D38 ledger detail). 5) SUB-AGENT OPENER in the info bar after the version token, hidden at zero. 6) VERSION v-prefix + OSC 8 hyperlink to the Gitea release with graceful degrade. D44 folds: A) model display in the info bar (local truncated name+size+style-if-applicable+ablation flag; cloud full), B) queue false-positive fix (task-focus enter with nothing running starts immediately), C) queued-work receipt renders in the tasks pane, D) queue steer hotkey. E) the pane doctrine governs all of it. Red-first each; -race; PTY proofs at 110 cols",
"next_action": "observe real outcome; capture lessons",
"non_goals": [],
"origin": "worker",
"regression_obligations": [],
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
"research_refs": [
"docs/architecture/boundary-law.md (D43 addendum 6 + D44)",
"src/go/rune-entry/internal/entry/taskqueue.go",
"tui/chatpane/chatpane.go",
"tui/cmd/rune-tui/main.go",
"tui/lanes/render.go",
"tui/tasks/render.go"
],
"resolution": "",
"rollback_ref": "git revert",
"scope": [
"scripts/admit_wave_thinking_bridge.py",
"src/go/rune-entry/internal/entry/taskqueue.go",
"src/go/rune-entry/internal/entry/taskqueue_test.go",
"tui/chatpane/chatpane.go",
"tui/chatpane/chatpane_test.go",
"tui/cmd/rune-tui/main.go",
"tui/cmd/rune-tui/main_test.go",
"tui/lanes/lanes.go",
"tui/lanes/lanes_test.go",
"tui/lanes/render.go",
"tui/lanes/render_test.go",
"tui/tasks/face.go",
"tui/tasks/render.go",
"tui/tasks/workblock.go",
"tui/tasks/workshop.go"
],
"toolchain": {},
"verification_obligations": [
"go test ./... in the touched tui packages green (also -race), go build ./... and go vet clean, gofmt clean",
"python-side taskqueue tests green where touched",
"tmux PTY proofs at 110 columns for each face-visible item with the freshly built binary"
]
},
"kind": "construction_contract",
"maturity": "DECLARED",
"observed_at": "2026-10-20T13:46:44Z",
"phase": "closed",
"status": "closed",
"tags": []
}
@@ -7412,3 +7412,19 @@
{"builder": "main-session-landing", "detail": "ADMIT-wave-scaffold-status-line \u2192 verifying", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T10:15:24Z", "operation": "transition"}
{"authority": "main-session own run", "detail": "race-green; raw-byte PTY glyph proof; arbitration pinned", "entity": "tests", "kind": "observed_consequence", "observed_at": "2026-10-20T10:15:45Z", "source": "go test", "task": "ADMIT-wave-scaffold-status-line"}
{"builder": "main-session-landing", "detail": "ADMIT-wave-scaffold-status-line closed", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T10:15:49Z", "operation": "close"}
{"builder": "wave-thinking-bridge", "detail": "ADMIT-wave-thinking-bridge \u2192 approved", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-20T12:46:09Z", "operation": "transition"}
{"builder": "wave-thinking-bridge", "detail": "ADMIT-wave-thinking-bridge \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-20T12:46:32Z", "operation": "transition"}
{"builder": "main-session-landing", "detail": "ADMIT-wave-thinking-bridge \u2192 verifying", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T13:46:15Z", "operation": "transition"}
{"authority": "main-session own run", "detail": "race-green", "entity": "tests", "kind": "observed_consequence", "observed_at": "2026-10-20T13:46:40Z", "source": "go test", "task": "ADMIT-wave-thinking-bridge"}
{"builder": "main-session-landing", "detail": "ADMIT-wave-thinking-bridge closed", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T13:46:44Z", "operation": "close"}
{"builder": "wave-d45-live-use-batch", "detail": "ADMIT-wave-d45-live-use-batch \u2192 approved", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-20T14:16:52Z", "operation": "transition"}
{"builder": "wave-d45-live-use-batch", "detail": "ADMIT-wave-d45-live-use-batch \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-20T14:17:13Z", "operation": "transition"}
{"builder": "wave-d46-live-defects", "detail": "ADMIT-wave-d46-live-defects \u2192 approved", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-20T18:24:50Z", "operation": "transition"}
{"builder": "wave-d46-live-defects", "detail": "ADMIT-wave-d46-live-defects \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-20T18:25:14Z", "operation": "transition"}
{"builder": "main-session-landing", "detail": "ADMIT-wave-d45-live-use-batch \u2192 verifying", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T21:21:00Z", "operation": "transition"}
{"authority": "main-session own run", "detail": "race-green; live 27B tool proofs; PTY at 110", "entity": "tests", "kind": "observed_consequence", "observed_at": "2026-10-20T21:21:18Z", "source": "go test", "task": "ADMIT-wave-d45-live-use-batch"}
{"builder": "main-session-landing", "detail": "ADMIT-wave-d45-live-use-batch closed", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T21:21:21Z", "operation": "close"}
{"at": "2026-10-20T23:03:51.828979+00:00", "builder": "wave-d46-live-defects", "contract": "ADMIT-wave-d46-live-defects", "kind": "scope-addendum", "note": "owner D46 addendum (law 0b610344) folded into the same wave: (1) clickable file artifacts in the tasks pane (OSC 8 file:// links, detect-then-render, in tui/tasks/workblock.go); (2) tool events leave the chat transcript for the tasks pane work stream (tui/cmd/rune-tui rowBubbles drops tool rows; tui/tasks/workblock.go gains the per-task tool-event history). The duplicate-contract guard refused a re-mint (contract already exists); this observation carries the widened surface: tui/tasks/, tui/cmd/rune-tui/, tui/runtime/lib/."}
{"builder": "main-session-landing", "detail": "ADMIT-wave-d46-live-defects \u2192 verifying", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T23:47:41Z", "operation": "transition"}
{"authority": "main-session own run", "detail": "46 packages race-green; dupe tripwire; budget pins; artifact links", "entity": "tests", "kind": "observed_consequence", "observed_at": "2026-10-20T23:47:59Z", "source": "go test", "task": "ADMIT-wave-d46-live-defects"}
{"builder": "main-session-landing", "detail": "ADMIT-wave-d46-live-defects closed", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T23:48:02Z", "operation": "close"}
+33 -1
View File
@@ -58,7 +58,8 @@ func newFakeServe(t *testing.T) *fakeServe {
_ = json.NewEncoder(w).Encode(map[string]any{
"identity": "rune", "role": "assistant", "current_task": nil,
"active_model": "stub-model-a",
"tasks_pending": 1, "tasks_running": 2, "tasks_completed": 3})
"tasks_pending": 1, "tasks_running": 2, "tasks_completed": 3,
"context": map[string]int{"used": 1337, "limit": 32768}})
})
mux.HandleFunc("/v1/conversation", func(w http.ResponseWriter, r *http.Request) {
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
@@ -506,3 +507,34 @@ func receiveLogText(b *Bridge) string {
}
return sb.String()
}
// TestLibrarySeamCarriesContextReadout — D45 item 2: the LIBRARY path
// (the face often runs via lib, not serve) must carry the status
// round's context{used,limit} into Ctx() exactly like the serve seam —
// the info bar reads m.rt.Ctx() → ctxToken on BOTH paths.
func TestLibrarySeamCarriesContextReadout(t *testing.T) {
fl := &fakeLib{
st: lib.StatusRow{Identity: "rune", CtxUsed: 4200, CtxLimit: 262144},
}
b := NewLibrary(fl, 5*time.Millisecond)
b.Start(nil)
t.Cleanup(b.Close)
waitFor(t, "ctx readout (lib seam)", func() bool {
used, limit := b.Ctx()
return used == 4200 && limit == 262144
})
}
// TestServeSeamCarriesContextReadout — D45 item 2, the serve hop:
// /v1/status body.Context.Used/Limit → setCtx → Ctx() → the info bar's
// ctxToken. The library twin is TestLibrarySeamCarriesContextReadout.
func TestServeSeamCarriesContextReadout(t *testing.T) {
f := newFakeServe(t)
b := New(NewServeClient(f.srv.URL), 5*time.Millisecond)
b.Start(nil)
t.Cleanup(b.Close)
waitFor(t, "ctx readout (serve seam)", func() bool {
used, limit := b.Ctx()
return used == 1337 && limit == 32768
})
}
+10
View File
@@ -36,3 +36,13 @@ func (b *Bridge) Commandable() bool {
}
return b.serve != nil
}
// QueueSteer posts one queue-steer mutation (D44-D) through the live
// seam's steer surface when it carries one; the serve path names the
// gap honestly (steer rides the library deployment).
func (b *Bridge) QueueSteer(ctx context.Context, req lib.QueueSteerRequest) (lib.QueueSteerResult, error) {
if s, ok := b.libSrc.(lib.QueueSteerer); ok && s != nil {
return s.QueueSteer(ctx, req)
}
return lib.QueueSteerResult{}, fmt.Errorf("bridge: no queue-steer seam on this source (%s)", b.SourceLabel())
}
+141 -70
View File
@@ -204,6 +204,13 @@ type Pane struct {
// scrollFade is the scrollbar layer's fade countdown (scroll.go's
// scrollbar layer): visible-on-activity, transparent-at-rest.
scrollFade int
// suppressLiveThinking is the D43 addendum 6 thinking-bridge
// handoff: while the bridge owns the live thinking animation, the
// chat pane suppresses the trailing empty (in-flight) thinking
// bubble — the pane declutters to conversation-only for the active
// turn. Completed thinking bubbles (content != "") stay (D43.7
// click-to-expand history).
suppressLiveThinking bool
}
// New returns an empty pane in the default (amalgamation) style.
@@ -299,11 +306,18 @@ const claimWalk = 12
// claimEcho finds the nearest trailing pending echo a durable row
// claims: the closest message at or before the tail with no durable ID
// and the row's role. ok=false when none is in reach (the row appends
// (or a face-local handle — the "local-…" ids the send path attaches
// for the consent hub; a face-local id is NOT durable identity and
// must never block the claim, the D46 double-render regression) and
// the row's role. ok=false when none is in reach (the row appends
// fresh).
func (p *Pane) claimEcho(m Message) (int, bool) {
for i := len(p.msgs) - 1; i >= 0 && len(p.msgs)-1-i < claimWalk; i-- {
if p.msgs[i].ID == "" && p.msgs[i].Role == m.Role {
id := p.msgs[i].ID
if id != "" && !strings.HasPrefix(id, "local-") {
continue
}
if p.msgs[i].Role == m.Role {
return i, true
}
}
@@ -349,6 +363,7 @@ func (p *Pane) Clone() *Pane {
n.expanded[k] = v
}
n.spinActive, n.spinFrame = p.spinActive, p.spinFrame
n.suppressLiveThinking = p.suppressLiveThinking
n.style = p.style
n.cycle = append([]rune(nil), p.cycle...)
n.shuffles = p.shuffles
@@ -372,6 +387,56 @@ func (p *Pane) SetSpin(active bool, frame int) {
p.spinFrame = frame
}
// SetSuppressLiveThinking hands the live thinking animation to the
// bridge (D43 addendum 6 item 1): while true the chat pane skips the
// trailing empty (in-flight) thinking bubble in its render. The pane
// still OWNS the bubble (the bridge reads its text); only the render
// is suppressed.
func (p *Pane) SetSuppressLiveThinking(on bool) { p.suppressLiveThinking = on }
// SuppressLiveThinking reports the bridge handoff flag (test seam).
func (p *Pane) SuppressLiveThinking() bool { return p.suppressLiveThinking }
// PendingThinkingText returns the trailing in-flight thinking bubble's
// surfaced text (the bridge's expand payload, D43 addendum 6 item 1).
// "" when no live thinking bubble is pending.
func (p *Pane) PendingThinkingText() string {
if len(p.msgs) == 0 {
return ""
}
last := p.msgs[len(p.msgs)-1]
if last.Role != RoleThinking || last.ID != "" {
return ""
}
return last.Content
}
// PendingThinkingTs returns the trailing in-flight thinking bubble's
// stamp (the bridge strip's far-left gutter, D43 addendum 6 item 2).
func (p *Pane) PendingThinkingTs() string {
if len(p.msgs) == 0 {
return ""
}
last := p.msgs[len(p.msgs)-1]
if last.Role != RoleThinking || last.ID != "" {
return ""
}
return last.Ts
}
// SpinCycle exposes the pane's shuffled glyph cycle so the bridge can
// render the same dots-walk vocabulary (D43 addendum 6 item 1 — the
// animation moves to the bridge; the vocabulary stays one source).
func (p *Pane) SpinCycle() []rune {
if len(p.cycle) == 0 {
return Vocab
}
return p.cycle
}
// SpinFrame reports the current animation frame (the bridge's walk).
func (p *Pane) SpinFrame() int { return p.spinFrame }
// AmendLastThinking fills the trailing PENDING thinking bubble (empty
// content, no durable ID) with the completion's surfaced thought text
// and reports whether one was found. D41 addendum 2: a LIVE bubble the
@@ -472,8 +537,15 @@ func (p *Pane) renderLines(w int, styled bool) ([]string, []ThinkingMark, []Expa
var thinks []ThinkingMark
var expands []ExpandMark
for i, m := range p.msgs {
// D46 item 2 (D43 addendum 6 item 1 executed): the chat pane
// renders NO thinking bubbles — live, completed, or durable
// history rows alike. The bridge strip between the panes and
// the composer owns thinking (its animation, its click-to-
// expose); the chat declutters to conversation only. The
// message DATA stays (the bridge reads the pending text;
// claims adopt onto it).
if m.Role == RoleThinking {
thinks = append(thinks, ThinkingMark{Line: len(lines), MsgIndex: i})
continue
}
start := len(lines)
lines = append(lines, p.bubble(m, w, styled)...)
@@ -673,33 +745,44 @@ func (p *Pane) thinkingBubble(m Message, ts string, w int, styled bool) []string
if styled {
ph := p.accentPhases()
st := ph[p.spinFrame%len(ph)]
room := max(w-len([]rune(Diamond))-len([]rune(label)), 1)
hang := 0
if ts != "" {
room = max(room-len([]rune(ts))-1, 1)
hang = len([]rune(ts)) + 1
}
room := max(w-hang-len([]rune(Diamond))-len([]rune(label)), 1)
line := runeStyle.Render(Diamond) + dimStyle.Render(label) +
st.Render(clip(p.dotsWalk(w), room))
if ts != "" {
if stampLeads() {
line = tsStyle.Render(ts+" ") + line
} else {
line += tsStyle.Render(" " + ts)
}
line = tsStyle.Render(ts) + " " + line
}
if right {
line = padLeftANSI(line, w)
// stamp stays far-left; the content right-aligns in the
// post-gutter room so the walk sits at the right edge.
body := line
if ts != "" {
body = strings.TrimPrefix(line, tsStyle.Render(ts)+" ")
body = padLeftANSI(body, w-hang)
body = tsStyle.Render(ts) + " " + body
} else {
body = padLeftANSI(line, w)
}
line = body
}
return []string{line}
}
line := clip(Diamond+label+p.dotsWalk(w), w)
hang := 0
if ts != "" {
if stampLeads() {
line = clip(ts+" "+line, w)
} else {
line = clip(line+" "+ts, w)
}
hang = len([]rune(ts)) + 1
}
if right {
room := max(w-hang, 1)
line := clip(Diamond+label+p.dotsWalk(w), room)
if ts != "" {
if right {
line = ts + " " + padLeft(line, w-hang)
} else {
line = ts + " " + pad(line, w-hang)
}
} else if right {
line = padLeft(line, w)
} else {
line = pad(line, w)
@@ -782,53 +865,51 @@ func (p *Pane) bubbleBox(styled bool, border, content, leadStyle lipgloss.Style,
}
out = append(out, "│ "+pad(clip(l, inner), inner)+" │")
}
// the bottom border row carries the stamp on its D43.3 side
// the bottom border row carries the stamp in the FAR-LEFT gutter
// (D43 addendum 6 item 2: always column 0, same position regardless
// of sender — the old corner-adjacent placement is superseded).
var bottom string
if styled {
bb := border.Render(bot)
if ts != "" {
if stampLeads() {
bottom = tsStyle.Render(ts+" ") + bb
} else {
bottom = bb + tsStyle.Render(" "+ts)
}
bottom = tsStyle.Render(ts) + " " + bb
} else {
bottom = bb
}
} else {
if ts != "" {
if stampLeads() {
bottom = ts + " " + bot
} else {
bottom = bot + " " + ts
}
bottom = ts + " " + bot
} else {
bottom = bot
}
}
out = append(out, bottom)
if right {
if stampLeads() {
// the box's right edge stays at w; the stamp hangs left of
// the bottom-left corner
for i, l := range out {
out[i] = padLeftANSI(l, w)
}
} else {
// the box right-aligns to w minus the stamp's hang; the
// bottom row's corner stamp rides AFTER the corner (the
// mirrored D43.3 placement), reaching exactly w
for i, l := range out {
if ts != "" && i == len(out)-1 {
out[i] = padLeftANSI(l, w)
continue
// right-hug: the box's right edge stays at w; the bottom row
// holds the stamp at column 0 and the box right-aligned in the
// post-gutter room (its right corner reaches w).
for i, l := range out {
if ts != "" && i == len(out)-1 {
var bb string
if styled {
bb = border.Render(bot)
} else {
bb = bot
}
out[i] = padLeftANSI(l, w-hang)
out[i] = padLeftANSI(bb, w-hang)
if styled {
out[i] = tsStyle.Render(ts) + " " + out[i]
} else {
out[i] = ts + " " + out[i]
}
continue
}
out[i] = padLeftANSI(l, w)
}
} else if stampLeads() && ts != "" {
// left-hug: the whole box indents so the stamp fits the gutter
// left of the bubble (the bottom row already starts at 0)
} else if ts != "" {
// left-hug: the whole box indents by the stamp's gutter so the
// stamp fits at column 0 left of the bubble; the bottom row
// already starts with the stamp at 0.
for i := range out {
if i == len(out)-1 {
continue
@@ -921,39 +1002,29 @@ var (
confirmBorderStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#FF79C6"))
)
// stampLine renders one text line carrying the dim timestamp on its
// D43.3 side (LTR leads the line with the stamp — the opposite side
// from the old trailing placement — RTL trails it), aligned to the
// line's D43.4 side.
// stampLine renders one text line with its timestamp in the FAR-LEFT
// gutter (D43 addendum 6 item 2: ALWAYS far-left, the same position
// regardless of sender — the old opposite-side/corner placements are
// superseded). The text renders in the remaining width, aligned to
// its D43.4 side (right right-aligns within the post-gutter room).
func stampLine(styled bool, st lipgloss.Style, text, ts string, w int, right bool) string {
if ts == "" {
return lineAligned(styled, st, text, w, right)
}
room := max(w-len([]rune(ts))-1, 1)
hang := len([]rune(ts)) + 1
room := max(w-hang, 1)
if styled {
var line string
if stampLeads() {
line = tsStyle.Render(ts+" ") + st.Render(clip(text, room))
} else {
line = st.Render(clip(text, room)) + tsStyle.Render(" "+ts)
}
body := st.Render(clip(text, room))
if right {
return padLeftANSI(line, w)
body = padLeftANSI(body, room)
}
return line
return tsStyle.Render(ts) + " " + body
}
if stampLeads() {
line := clip(ts+" "+text, w)
if right {
return padLeft(line, w)
}
return pad(line, w)
}
line := clip(text+" "+ts, w)
body := clip(text, room)
if right {
return padLeft(line, w)
body = padLeft(body, room)
}
return pad(line, w)
return ts + " " + body
}
// lineAligned renders one line at its D43.4 side: right right-aligns
+205 -87
View File
@@ -132,32 +132,22 @@ func TestRightSideIsOneDecision(t *testing.T) {
// style these are ROUNDED BUBBLES (border rows carry the side).
// (Superseded by TestUserLeftAssistantRight — D43.4 flipped the pair.)
// TestThinkingCollapsedByDefault: a thinking bubble renders as ONE dim
// summary line until toggled open — collapsed by default (design
// intent).
// TestThinkingCollapsedByDefault — SUPERSEDED by D46 item 2 (the
// chat pane drops the thinking render; the bridge owns thinking). The
// open/collapse STATE machinery survives as data; nothing renders.
func TestThinkingCollapsedByDefault(t *testing.T) {
p := New()
p.Append(Message{ID: "t1", Role: RoleThinking,
Content: "line one\nline two\nline three"})
lines := p.Render(40, 8, false)
if n := countNonEmpty(lines); n != 1 {
t.Fatalf("collapsed thinking must be one line, got %d:\n%v", n, lines)
}
if !strings.Contains(tailLine(lines), "thinking (3 lines)") {
t.Errorf("collapsed summary line wrong: %q", tailLine(lines))
if n := countNonEmpty(lines); n != 0 {
t.Fatalf("the chat pane renders NO thinking (D46), got %d lines:\n%v", n, lines)
}
if n := p.ToggleThinking(); n != 1 {
t.Fatalf("toggle flipped %d bubbles, want 1", n)
t.Fatalf("toggle state still flips (data), got %d", n)
}
lines = p.Render(40, 8, false)
if n := countNonEmpty(lines); n != 4 { // header + 3 body lines
t.Fatalf("expanded thinking must show its body, got %d lines:\n%v", n, lines)
}
if !strings.Contains(lines[len(lines)-4], "◆ thinking") {
t.Errorf("expanded header wrong: %q", lines[len(lines)-4])
}
if n := p.ToggleThinking(); n != 1 || countNonEmpty(p.Render(40, 8, false)) != 1 {
t.Fatal("second toggle must collapse again")
if n := p.ToggleThinking(); n != 1 {
t.Fatalf("second toggle must flip back, got %d", n)
}
}
@@ -293,80 +283,44 @@ func TestTimestampsRenderBesideMessages(t *testing.T) {
// ── D35: the runic thinking toggle glyph ─────────────────────────────
// TestRunicThinkingToggleGlyph: every thinking bubble line carries the
// runic identity glyph (U+16B1) as its expandable control marker —
// collapsed and expanded.
// TestRunicThinkingToggleGlyph — SUPERSEDED by D46 item 2: the chat
// pane renders no thinking, so no runic control lines appear there.
// (The glyph vocabulary lives on in the bridge strip.)
func TestRunicThinkingToggleGlyph(t *testing.T) {
p := New()
p.Append(Message{ID: "t1", Role: RoleThinking, Content: "deep\nthoughts"})
lines := p.Render(40, 4, false)
if !strings.Contains(tailLine(lines), Diamond) {
t.Fatalf("collapsed thinking line missing the diamond:\n%q", tailLine(lines))
}
if !strings.Contains(tailLine(lines), "thinking (2 lines)") {
t.Fatalf("collapsed summary lost its line count:\n%q", tailLine(lines))
}
p.ToggleThinking()
lines = p.Render(40, 6, false)
if !strings.Contains(lines[len(lines)-3], Diamond) {
t.Fatalf("expanded thinking header missing the diamond:\n%q", lines[len(lines)-3])
if n := countNonEmpty(lines); n != 0 {
t.Fatalf("no thinking lines may render in chat (D46):\n%v", lines)
}
}
// ── D34 slice 3: the live thinking marker ────────────────────────────
// TestLiveThinkingMarkerSpins: a thinking bubble with EMPTY content is
// the live one — its line carries the DOTS WALK (D41 addendum 2): the
// diamond holds steady purple, dots appear left to right as fresh
// glyphs from the pane's own shuffled cycle, a full row restarts from
// the left. Spinner off: the diamond alone.
// TestLiveThinkingMarkerSpins — the walk MATH survives (the bridge
// strip renders it from the pane's one shuffled cycle, D46 item 2);
// the chat pane itself renders nothing for the live bubble.
func TestLiveThinkingMarkerSpins(t *testing.T) {
p := New()
p.Append(Message{Role: RoleThinking, Content: ""})
p.SetSpin(true, 0)
cycle := p.cycle
cycle := p.SpinCycle()
if len(cycle) != len(Vocab) {
t.Fatalf("live pane must hold its own shuffled cycle, got %d glyphs", len(cycle))
}
fit := max(min((40-32)/2, 3), 1) // D43.13: the walk is capped at 3
for f := 0; f < fit*3; f++ {
p.SetSpin(true, f) // activation only reshuffles on the rising edge
p.spinFrame = f
n := 1 + f%fit
restart := f / fit
want := make([]rune, 0, n)
for i := 0; i < n; i++ {
want = append(want, cycle[(restart+i)%len(cycle)])
}
line := tailLine(p.Render(40, 4, false))
// D43.13: "◆ thinking · ctrl+t expands · <walk>" — the hint
// sits immediately after the label, BEFORE the animation.
if !strings.Contains(line, Diamond+" thinking · ctrl+t expands · ") {
t.Fatalf("frame %d: line must carry the D43.13 shape: %q", f, line)
}
hint := strings.Index(line, "ctrl+t expands")
walk := strings.Index(line, "· ")
last := strings.LastIndex(line, "· ")
if hint < 0 || last < 0 || last < hint {
t.Fatalf("frame %d: hint must precede the walk: %q", f, line)
}
_ = walk
for i, g := range want {
if !strings.ContainsRune(line, g) {
t.Fatalf("frame %d: walk dot %d (%q) missing: %q", f, i, string(g), line)
if g := cycle[(restart+i)%len(cycle)]; !strings.ContainsRune(string(cycle), g) {
t.Fatalf("frame %d: walk glyph %d missing from the cycle", f, i)
}
}
}
p.SetSpin(false, 1)
p.spinFrame = 0
line := tailLine(p.Render(40, 3, false))
if !strings.Contains(line, Diamond) {
t.Fatalf("spinner off must still show the diamond: %q", line)
}
for _, g := range Vocab {
if strings.Contains(line[:min(14, len(line))], string(g)) {
t.Fatalf("spinner off but the walk still shows %q: %q", string(g), line)
}
if lines := p.Render(40, 4, false); countNonEmpty(lines) != 0 {
t.Fatalf("the live thinking bubble must not render in chat (D46):\n%v", lines)
}
}
@@ -386,8 +340,9 @@ func TestAmendAndDropPendingThinking(t *testing.T) {
if msgs[0].Content != "step one\nstep two" {
t.Fatalf("amended content = %q", msgs[0].Content)
}
if line := tailLine(p.Render(40, 3, false)); !strings.Contains(line, "thinking (2 lines)") {
t.Fatalf("amended bubble must summarize its lines: %q", line)
// D46: the amended bubble's DATA feeds the bridge; chat renders nothing.
if lines := p.Render(40, 3, false); countNonEmpty(lines) != 0 {
t.Fatalf("amended thinking must not render in chat (D46):\n%v", lines)
}
p.Append(Message{Role: RoleThinking, Content: ""})
if !p.DropLastThinking() {
@@ -559,20 +514,11 @@ func TestLiveMarkerBreathesThroughPhases(t *testing.T) {
p := New()
p.Append(Message{Role: RoleThinking, Content: ""})
p.SetSpin(true, 0)
seen := map[string]bool{}
for frame := 0; frame < 3; frame++ {
p.SetSpin(true, frame)
line := tailLine(p.Render(40, 4, true))
if !strings.Contains(line, "38;2;183;154;255") && !strings.Contains(line, "196;160;255") && !strings.Contains(line, "C4A0FF") {
// the diamond's steady purple (runeStyle #C4A0FF = 38;2;196;160;255)
if !strings.Contains(line, "38;2;196;160;255") {
t.Fatalf("frame %d: diamond lost its steady purple:\n%q", frame, line)
}
if lines := p.Render(40, 4, true); countNonEmpty(lines) != 0 {
t.Fatalf("frame %d: thinking must not render in chat (D46):\n%v", frame, lines)
}
seen[line] = true
}
if len(seen) < 3 {
t.Fatalf("three frames must render three distinct breathing lines, got %d", len(seen))
}
}
@@ -588,10 +534,9 @@ func TestLiveBubbleToggleCarriesIntoAmended(t *testing.T) {
if !p.AmendLastThinking("thought one\nthought two") {
t.Fatal("amend must find the trailing live bubble")
}
lines := p.Render(40, 10, false)
joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "thought one") {
t.Fatalf("amended thoughts must render EXPANDED (open carried):\n%s", joined)
// D46: the data carries (the bridge owns its display); chat stays silent
if lines := p.Render(40, 10, false); countNonEmpty(lines) != 0 {
t.Fatalf("thinking must not render in chat (D46):\n%v", lines)
}
}
@@ -636,6 +581,38 @@ func TestDurableThinkingRowClaimsLiveBubble(t *testing.T) {
// law lacked (exact-content thinking claims; last-message-only
// claims), so a regression back to the old claim turns the suite RED.
// TestD46ChatPaneDropsThinkingRender — D46 item 2 (addendum-6 item 1
// executed): the chat pane renders NO thinking bubbles — not the live
// pending one, not the completed/amended one, not the durable
// "<id>/thinking" history rows. The bridge strip owns thinking (lanes).
// The DATA stays (AmendLastThinking, PendingThinkingText) — only the
// chat render is dropped.
func TestD46ChatPaneDropsThinkingRender(t *testing.T) {
p := New()
p.Append(Message{Role: RoleUser, Content: "the question"})
p.Append(Message{Role: RoleThinking, Content: ""})
p.AmendLastThinking("the surfaced thought")
p.Append(Message{ID: "m1/thinking", Role: RoleThinking, Content: "durable thought"})
p.Append(Message{ID: "m1", Role: RoleAssistant, Content: "the answer"})
lines := p.Render(60, 40, true)
body := strings.Join(lines, "\n")
if strings.Contains(body, "thinking ·") {
t.Fatalf("the chat pane must render conversation only — thinking belongs to the bridge:\n%s", body)
}
if !strings.Contains(body, "the question") || !strings.Contains(body, "the answer") {
t.Fatalf("conversation facts must still render:\n%s", body)
}
if _, marks := p.RenderWithMarks(60, 40, true); len(marks) != 0 {
t.Fatalf("no thinking control regions may be declared from the chat pane, got %d", len(marks))
}
// the data path survives (the bridge reads it)
if got := p.PendingThinkingText(); got != "" {
// the tail is the durable answer now; the pending text is gone —
// the bridge holds its own text for the active turn only
t.Logf("pending thinking text after the turn: %q", got)
}
}
// TestNoDupeAnswerCompletionPlusDurableRow — the owner's duplicate,
// reproduced then pinned: the completion-applied reply bubble and the
// polled durable row are ONE fact even when the durable content
@@ -716,6 +693,89 @@ func TestOneThinkingStream(t *testing.T) {
}
}
// TestD46NoDupeFullSendReplyPollCycle — THE D46 TRIPWIRE (never again):
// a full send→reply→poll cycle renders each fact exactly once, counted
// as rendered bubbles vs unique msg_ids. The regression this pins: the
// live user echo now carries a face-local id ("local-…", the consent
// hub's handle) — the claim law must still claim it, else the polled
// durable row appends a SECOND user bubble.
func TestD46NoDupeFullSendReplyPollCycle(t *testing.T) {
p := New()
// live send: echo WITH the face-local id (the D46 regression shape)
p.Append(Message{ID: "local-1719000000", Role: RoleUser, Content: "hello rune"})
p.Append(Message{Role: RoleThinking, Content: ""})
p.AmendLastThinking("thoughts")
p.Append(Message{Role: RoleAssistant, Content: "hi there"})
// the poll: the durable conversation window
p.Append(Message{ID: "m0", Role: RoleUser, Content: "hello rune"})
p.Append(Message{ID: "m1", Role: RoleAssistant, Content: "hi there"})
unique := map[string]bool{}
for _, m := range p.Messages() {
if m.Role == RoleUser || m.Role == RoleAssistant {
id := m.ID
if id == "" {
id = "rc:" + m.Content
}
unique[id] = true
}
}
users, assistants := 0, 0
for _, m := range p.Messages() {
switch m.Role {
case RoleUser:
users++
case RoleAssistant:
assistants++
}
}
if users != 1 || assistants != 1 {
t.Fatalf("D46 no-dupe broken: %d user bubbles, %d answer bubbles, %d unique ids:\n%+v",
users, assistants, len(unique), p.Messages())
}
if got := p.Messages()[0].ID; got != "m0" {
t.Fatalf("the durable record must adopt onto the claimed echo: %+v", p.Messages()[0])
}
}
// TestD46NoDupeBootRestoreAndBackfill — the tripwire's other two paths:
// the boot window and the scroll backfill each land every durable row
// exactly once (no bubble count exceeds the unique msg_id count).
func TestD46NoDupeBootRestoreAndBackfill(t *testing.T) {
// boot-restore: the first window appends its rows once
p := New()
for _, r := range []Message{
{ID: "b0", Role: RoleUser, Content: "q0"},
{ID: "b1", Role: RoleAssistant, Content: "a0"},
{ID: "b2", Role: RoleUser, Content: "q1"},
} {
p.Append(r)
}
// a repeated boot window (a second poll of the same rows) lands nothing
for _, r := range []Message{
{ID: "b0", Role: RoleUser, Content: "q0"},
{ID: "b1", Role: RoleAssistant, Content: "a0"},
{ID: "b2", Role: RoleUser, Content: "q1"},
} {
p.Append(r)
}
// backfill: the deeper page prepends the OLDER rows once; a repeated
// page lands nothing
p.Prepend(Message{ID: "a-1", Role: RoleUser, Content: "older q"})
p.Prepend(Message{ID: "a-1", Role: RoleUser, Content: "older q"})
counts := map[string]int{}
for _, m := range p.Messages() {
counts[m.ID]++
}
for id, n := range counts {
if n != 1 {
t.Fatalf("D46 no-dupe broken on %s: %d bubbles for one msg_id:\n%+v", id, n, p.Messages())
}
}
if p.Len() != 4 {
t.Fatalf("boot+backfill hold 4 facts, len=%d:\n%+v", p.Len(), p.Messages())
}
}
// TestClaimedEchoKeyFreesRepeats — claiming an echo frees its
// role+content key: a SECOND identical send later must still render
// (the stale rc-key would have swallowed it).
@@ -744,9 +804,13 @@ func TestThinkingToggleCarriesAcrossTheClaim(t *testing.T) {
t.Fatal("the owner expanded the live bubble")
}
p.Append(Message{ID: "m1/thinking", Role: RoleThinking, Content: "thought\nlines"})
lines := p.Render(40, 8, false)
if n := countNonEmpty(lines); n < 3 {
t.Fatalf("the claimed bubble must stay expanded:\n%v", lines)
// D46: the claim still adopts (data); the chat render stays empty
msgs := p.Messages()
if len(msgs) != 1 || msgs[0].ID != "m1/thinking" {
t.Fatalf("the durable thinking row must claim the live bubble:\n%+v", msgs)
}
if lines := p.Render(40, 8, false); countNonEmpty(lines) != 0 {
t.Fatalf("thinking must not render in chat (D46):\n%v", lines)
}
}
@@ -959,3 +1023,57 @@ func containsWhole(s, word string) bool {
}
return false
}
// TestTimestampsFarLeftGutterBothSenders — D45 item 5 (the addendum-6
// re-land): EVERY stamp-bearing row starts its timestamp at column 0,
// the same position regardless of sender — the old opposite-side /
// corner-adjacent placements are superseded.
func TestTimestampsFarLeftGutterBothSenders(t *testing.T) {
if rtl {
t.Skip("stamp side pinned for the LTR machine")
}
for _, w := range []int{40, 63, 110} {
p := New()
p.Append(Message{ID: "u1", Role: RoleUser, Ts: "2026-10-18T12:00:00Z",
Content: "user side line"})
p.Append(Message{ID: "a1", Role: RoleAssistant, Ts: "2026-10-18T12:01:00Z",
Content: "rune side line"})
type stampRow struct {
col int
boxCol int
}
var rows []stampRow
for _, l := range p.Render(w, 12, false) {
trimmed := strings.TrimLeft(l, " ")
if len(trimmed) < 5 || trimmed[2] != ':' {
continue
}
if _, err := time.Parse("15:04", trimmed[:5]); err != nil {
continue
}
col := strings.Index(l, trimmed[:5])
rest := l[col+5:]
boxCol := -1
for i, r := range rest {
if r == '╰' {
boxCol = col + 5 + i
break
}
}
rows = append(rows, stampRow{col: col, boxCol: boxCol})
}
if len(rows) != 2 {
t.Fatalf("w=%d: expected both senders' stamps, found %d:\n%s",
w, len(rows), strings.Join(p.Render(w, 12, false), "\n"))
}
if rows[0].col != rows[1].col {
t.Fatalf("w=%d: stamps sit at DIFFERENT columns (%d vs %d) — the corner placement persists:\n%s",
w, rows[0].col, rows[1].col, strings.Join(p.Render(w, 12, false), "\n"))
}
for _, r := range rows {
if r.boxCol >= 0 && r.boxCol < r.col {
t.Fatalf("w=%d: bubble renders LEFT of the stamp — not the far-left gutter", w)
}
}
}
}
+25 -23
View File
@@ -582,39 +582,41 @@ func raw(styled bool, st lipgloss.Style, text string, w int) string {
return clip(text, w)
}
// stampClip appends the dim timestamp to one left line on its D43.3
// side (LTR leads the line with it — the opposite side from the old
// trailing placement; RTL trails): styled clips by display width first
// (the padding law); plain clips text+stamp together.
// stampClip renders one left line with its timestamp in the FAR-LEFT
// gutter (D43 addendum 6 item 2: always column 0, same position
// regardless of sender). Styled clips by display width first (the
// padding law); plain clips text+stamp together.
func stampClip(styled bool, l, ts string, w int) string {
if !styled {
if stampLeads() {
return clip(ts+" "+l, w)
if ts == "" {
if styled {
return clipANSI(l, w)
}
return clip(l+" "+ts, w)
return clip(l, w)
}
room := max(w-widthANSI(ts)-1, 1)
if stampLeads() {
return tsStyle.Render(ts+" ") + clipANSI(l, room)
hang := widthANSI(ts) + 1
room := max(w-hang, 1)
if !styled {
return clip(ts+" "+clip(l, room), w)
}
return clipANSI(l, room) + tsStyle.Render(" "+ts)
return tsStyle.Render(ts) + " " + clipANSI(l, room)
}
// stampRightANSI appends the dim timestamp to one right-aligned line on
// its D43.3 side and right-aligns the result (the rune side's stamp
// law).
// stampRightANSI renders one right-aligned line with its timestamp in
// the FAR-LEFT gutter (D43 addendum 6 item 2): the stamp sits at column
// 0 and the content right-aligns in the post-gutter room.
func stampRightANSI(styled bool, l, ts string, w int) string {
if !styled {
if stampLeads() {
return padLeft(clip(ts+" "+l, w), w)
if ts == "" {
if styled {
return padLeftANSI(clipANSI(l, w), w)
}
return padLeft(clip(l+" "+ts, w), w)
return padLeft(clip(l, w), w)
}
room := max(w-widthANSI(ts)-1, 1)
if stampLeads() {
return padLeftANSI(tsStyle.Render(ts+" ")+clipANSI(l, room), w)
hang := widthANSI(ts) + 1
room := max(w-hang, 1)
if !styled {
return ts + " " + padLeft(clip(l, room), room)
}
return padLeftANSI(clipANSI(l, room)+tsStyle.Render(" "+ts), w)
return tsStyle.Render(ts) + " " + padLeftANSI(clipANSI(l, room), room)
}
// ── ANSI-width math on stripped content (the drawPane padding law) ───
+7 -12
View File
@@ -262,21 +262,16 @@ func TestScrollMarksReIndex(t *testing.T) {
for i := 0; i < 6; i++ {
p.Append(Message{ID: itoa(i), Role: RoleSystem, Content: "m" + itoa(i)})
}
// D46 item 2: the chat pane declares NO thinking control regions —
// the bridge strip owns the exposure. The marks law pins at zero
// on every scroll position.
_, marks := p.RenderWithMarks(30, 8, false)
// bottom-up anchor: 7 lines in an 8-row window pad ONE empty row on
// top, so the thinking bubble's window row is 1 (not 0)
if len(marks) != 1 || marks[0].Line != 1 {
t.Fatalf("unscrolled mark = %+v, want line 1 (anchor pad)", marks)
}
p.ScrollUp(2) // window slides above the thinking bubble → mark gone
_, marks = p.RenderWithMarks(30, 4, false)
if len(marks) != 0 {
t.Fatalf("off-window thinking mark must drop out: %+v", marks)
t.Fatalf("thinking marks must pin at zero (D46 bridge move): %+v", marks)
}
p.ScrollDown(1000)
_, marks = p.RenderWithMarks(30, 8, false)
if len(marks) != 1 || marks[0].Line != 1 {
t.Fatalf("mark must return with the window: %+v", marks)
p.ScrollUp(2)
if _, marks = p.RenderWithMarks(30, 4, false); len(marks) != 0 {
t.Fatalf("thinking marks must pin at zero scrolled: %+v", marks)
}
}
+14 -1
View File
@@ -136,7 +136,20 @@ func TestConsentCardPTYFullFlow(t *testing.T) {
t.Fatalf("y write: %v", err)
}
// the admission's verdict: delegated now or queued behind — either
// way the receipt names the brief snapshot and its context refs
// way the receipt names the brief snapshot and its context refs.
// D44-C: the receipt renders in the TASKS PANE (the tree region
// hosts the queue rows), never the chat pane — open the tree first
// so the receipt line is on screen.
if _, err := master.Write([]byte("\x1b[B")); err != nil { // down: arrow keys route the face chain
t.Fatalf("arrow write: %v", err)
}
// the receipt renders in the tree region's notice line — open the
// tree via the QUEUE STEER hotkey (D44-D: ctrl+q opens the tree
// region hosting the queue rows; the palette road also works but
// needs typed filtering), then wait for the receipt markers.
if _, err := master.Write([]byte("\x11")); err != nil { // ctrl+q
t.Fatalf("ctrl+q write: %v", err)
}
waitFor([]string{"work started from chat (", "work queued from chat (#"}, 60*time.Second)
// clean detach
+10 -5
View File
@@ -180,15 +180,20 @@ func TestConsentYesCarriesTheBrief(t *testing.T) {
if req.Text != "fix the login timeout" {
t.Errorf("the demand must carry the consented statement verbatim, got %q", req.Text)
}
// the enqueue rides the K20 off-thread leg: fold its receipt
// the enqueue rides the K20 off-thread leg: fold its receipt.
// D44-C: work receipts render in the TASKS PANE (the tree region),
// never the chat pane.
brief := consent.BriefRecord{SnapshotID: "brief-1", BriefText: "fix the login timeout",
EvidenceMsgIDs: []string{msgID}}
final, _ := m.Update(consentEnqueueMsg{brief: brief, res: fq.result})
m = final.(model)
txt := chatBubbleText(m)
if !strings.Contains(txt, "work started from chat (brief brief-1") ||
!strings.Contains(txt, "context: "+msgID) {
t.Fatalf("the receipt must name the brief snapshot + carried context refs:\n%s", txt)
got := m.face.QueueNotice()
if !strings.Contains(got, "work started from chat (brief brief-1") ||
!strings.Contains(got, msgID) {
t.Fatalf("the receipt must name the brief snapshot + carried context refs in the tasks pane, got %q", got)
}
if txt := chatBubbleText(m); strings.Contains(txt, "work started from chat") {
t.Fatalf("the work receipt must NOT render in the chat pane:\n%s", txt)
}
}
+40
View File
@@ -0,0 +1,40 @@
package main
// d46_addendum_test.go — the D46 owner addendum's chat-declutter pair:
// (1) tool events leave the chat transcript (a polled tool-role row
// renders NOTHING in the chat pane — the tasks pane's work stream owns
// tool detail); (2) together with the thinking-bridge move (D46 item
// 2) the chat pane lands conversational roles only, once and clean.
import (
"testing"
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/runtime/lib"
)
// TestD46ToolRowsRenderNothingInChat — addendum item 2 (the C7/pane-
// doctrine boundary): rowBubbles projects ONLY conversational roles;
// a tool-role conversation row (the operator's persisted tool records)
// projects to NO chat bubble — its home is the tasks pane's work
// stream (workblock.go reads the same wire).
func TestD46ToolRowsRenderNothingInChat(t *testing.T) {
got := rowBubbles(lib.ConvRow{MsgID: "t0", Role: "tool",
Content: "list_directory /home/nick/rune", Ts: "2026-10-20T18:00:00Z"})
if len(got) != 0 {
t.Fatalf("tool events must not project into the chat pane (D46 addendum): %+v", got)
}
}
// TestD46ConversationalRowsStillProject — the declutter never eats the
// conversation: user/assistant rows project exactly one bubble each.
func TestD46ConversationalRowsStillProject(t *testing.T) {
u := rowBubbles(lib.ConvRow{MsgID: "m0", Role: "user", Content: "q", Ts: "t"})
a := rowBubbles(lib.ConvRow{MsgID: "m1", Role: "assistant", Content: "a", Ts: "t"})
if len(u) != 1 || u[0].Role != chatpane.RoleUser {
t.Fatalf("user row must project one user bubble: %+v", u)
}
if len(a) != 1 || a[0].Role != chatpane.RoleAssistant {
t.Fatalf("assistant row must project one answer bubble: %+v", a)
}
}
+159
View File
@@ -0,0 +1,159 @@
// infobar_test.go — the info bar's D43 addendum 6 item 5/6 + D44-A
// frozen suite: the v-prefixed version (OSC 8 hyperlink only where the
// terminal detection wires it — hermetic tests keep the plain degrade),
// the sub-agent opener (" · waves N" after the version, hidden at
// zero), and the compacted active model (a 70-char local filename
// never eats the bar).
package main
import (
"fmt"
"strings"
"testing"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/tasks"
)
// wavesFlightOf builds a real flight observation with n rows.
func wavesFlightOf(n int) tasks.WaveFlight {
rows := make([]tasks.WaveRow, n)
for i := range rows {
rows[i] = tasks.WaveRow{ID: fmt.Sprintf("wave-%d", i), Title: "wave title", State: "building"}
}
return tasks.WaveFlight{Kind: "running", Count: n, Rows: rows}
}
func TestVersionTokenVPrefixPlainByDefault(t *testing.T) {
linkVersions = func() bool { return false }
defer func() { linkVersions = func() bool { return false } }()
m := sized(t, initialModel(), 80, 24)
m.version = "0.321.0"
got := semantic.StripANSI(versionToken(m))
if got != "rune v0.321.0" {
t.Fatalf("plain degrade must render the v-prefixed semver exactly, got %q", got)
}
}
func TestVersionTokenHyperlinkWhenDetected(t *testing.T) {
linkVersions = func() bool { return true }
defer func() { linkVersions = func() bool { return false } }()
m := sized(t, initialModel(), 80, 24)
m.version = "0.321.0"
got := versionToken(m)
// the OSC 8 wrap: link start + text + link end (byte-check)
if !strings.Contains(got, "\x1b]8;;") {
t.Fatalf("detected terminal must carry the OSC 8 hyperlink, got %q", got)
}
if !strings.Contains(got, "http://127.0.0.1:3000/conductor/rune/releases/tag/tui-v0.321.0") {
t.Fatalf("the link must target the Gitea tui-v tag release, got %q", got)
}
if plain := semantic.StripANSI(got); plain != "rune v0.321.0" {
t.Fatalf("the hyperlinked segment must strip to the plain form, got %q", plain)
}
// a describe-form version still links to its semver-core tag
m.version = "0.321.0-56-g449e1efa"
if got := versionToken(m); !strings.Contains(got, "/tag/tui-v0.321.0") {
t.Fatalf("the describe form must link to the core tag, got %q", got)
}
// a pure dev form links to the releases list (no tag exists)
m.version = "dev-g449e1efa"
if got := versionToken(m); !strings.Contains(got, "/releases") || strings.Contains(got, "/tag/") {
t.Fatalf("the dev form must link to the releases list, got %q", got)
}
}
func TestWavesTokenOpener(t *testing.T) {
m := sized(t, initialModel(), 80, 24)
if got := wavesToken(m); got != "" {
t.Fatalf("zero flight must hide the opener, got %q", got)
}
m.face.SetWaveFlight(wavesFlightOf(3))
if got := wavesToken(m); got != " · waves 3" {
t.Fatalf("the opener rides after the version token, got %q", got)
}
}
func TestModelTokenCompaction(t *testing.T) {
m := sized(t, initialModel(), 80, 24)
// D45 #1/2: the bar carries the engine's active model even without
// an explicit selection record — compaction keeps it short either
// way.
if got := semantic.StripANSI(modelToken(m)); len(got) > 40 {
t.Fatalf("the model token must stay compact, got %q", got)
}
// LOCAL: the hf.co/NAME-...-GGUF:QUANT pattern truncates to name +
// size + ablation — never the 70-char filename
m.prov = &providerSelection{Provider: "local",
Model: "hf.co/bartowski/Qwen2.5-Coder-7B-Instruct-abliterated-Q6_K-GGUF:Q6_K"}
got := modelToken(m)
if len(semantic.StripANSI(got)) > 40 {
t.Fatalf("a local filename must never eat the bar, got %q", got)
}
if !strings.Contains(got, "7B") {
t.Fatalf("the size marker must survive the compaction, got %q", got)
}
if !strings.Contains(got, "abliterated") {
t.Fatalf("the ablation flag must survive the compaction, got %q", got)
}
if strings.Contains(got, "GGUF") || strings.Contains(got, "hf.co/") {
t.Fatalf("quant/repo tails must drop, got %q", got)
}
// CLOUD: the full name stays
m.prov = &providerSelection{Provider: "openai", Model: "gpt-4o-mini"}
if got := modelToken(m); !strings.Contains(got, "gpt-4o-mini") {
t.Fatalf("cloud keeps its full name, got %q", got)
}
}
func TestCompactModelUnit(t *testing.T) {
cases := []struct {
provider, model string
want string
}{
// the D45 #2 refined spec: lowercase local base + size +
// markers, no paths/quants/GGUF noise
{"local", "hf.co/bartowski/Llama-3.1-8B-Instruct-GGUF:Q4_K_M", "llama-3.1 8B"},
{"local", "llama3.2:latest", "llama3.2:latest"}, // already compact: verbatim
{"local", "hf.co/user/qwen3.6-27b-uncensored-GGUF:IQ3_M", "qwen3.6 27B · uncensored"},
}
for _, c := range cases {
if got := compactModel(c.provider, c.model); got != c.want {
t.Errorf("compactModel(%q, %q) = %q, want %q", c.provider, c.model, got, c.want)
}
}
}
// TestCompactModelD45RefinedSpec — the owner's D45 refined display law,
// frozen red-first: parse the id for DISPLAY as base name + param size +
// ablation marker only ('qwen3.6 27B · uncensored'), dropping hf.co/
// prefixes, org paths, GGUF/quant suffixes and the marketing tail. An
// id with nothing to parse shows verbatim (the honest no-op).
func TestCompactModelD45RefinedSpec(t *testing.T) {
cases := []struct{ provider, in, want string }{
// the owner's live model (the exact id on the wire today)
{"", "hf.co/DavidAU/Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF:IQ3_M",
"qwen3.6 27B · uncensored"},
{"", "orcarouter/Qwen3.8-27B-Uncensored:IQ2_M", "qwen3.8 27B · uncensored"},
{"", "hf.co/bartowski/Mistral-Small-24B-Abliterated-GGUF:Q4_K_M", "mistral-small 24B · abliterated"},
{"", "qwen2.5:7b", "qwen2.5 7B"},
{"", "llava:7b", "llava 7B"},
{"", "qwen3:14b", "qwen3 14B"},
{"", "qwen2.5:0.5b", "qwen2.5 0.5B"},
// nothing parsed: the id shows verbatim
{"", "phi4:latest", "phi4:latest"},
// cloud: vendor + model, unmangled
{"openai", "gpt-5.2-mini", "gpt-5.2-mini"},
{"anthropic", "claude-sonnet-4-5", "claude-sonnet-4-5"},
}
for _, c := range cases {
if got := compactModel(c.provider, c.in); got != c.want {
t.Errorf("compactModel(%q, %q) = %q, want %q", c.provider, c.in, got, c.want)
}
}
// the bar never gets eaten
got := compactModel("", "hf.co/DavidAU/Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF:IQ3_M")
if r := []rune(got); len(r) > 32 {
t.Errorf("compacted name %d runes eats the bar: %s", len(r), got)
}
}
+425 -27
View File
@@ -162,6 +162,7 @@ type model struct {
km *nav.KeyMap
disp *laneconc.Dispatcher
chatBusy bool
lastCtrlC bool // the exit-gesture arm: second ctrl+c quits (D46)
chatSt string // "" (never sent) | sending | waiting | received | error
pendingSent string // the in-flight text (the waiting leg's match)
spin int // the animation frame (the glyph cycle's index)
@@ -409,6 +410,33 @@ func adaptQueueRows(rows []lib.QueueRow) []taskqueue.Entry {
return out
}
// sendSteerCommand takes the face's queued steer mutation (if any) and
// returns a tea.Cmd that executes it through the live seam's steer
// surface (D44-D). No face mutation happens off the Update thread.
func sendSteerCommand(m model) tea.Cmd {
if m.rt == nil {
return nil
}
cmd, ok := m.face.TakeSteerCommand()
if !ok {
return nil
}
exec := func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := m.rt.QueueSteer(ctx, lib.QueueSteerRequest{
Action: cmd.Action, EntryID: cmd.EntryID, Delta: cmd.Delta})
return steerReceiptMsg{cmd: cmd, err: err}
}
return exec
}
// steerReceiptMsg is one answered queue-steer mutation.
type steerReceiptMsg struct {
cmd tasks.SteerCommand
err error
}
// sendTaskCommand takes the face's queued command (if any) and returns
// a tea.Cmd that executes it through the live seam. No face mutation
// happens off the Update thread — the goroutine only touches the seam.
@@ -506,6 +534,11 @@ func chainOwnsKey(m model, k nav.Key) bool {
// in Update; the cycle self-extinguishes when the dispatch lands).
type spinTickMsg struct{}
// ctrlCArmReset disarms the double-ctrl+c exit window after 1s (D46 exit gesture).
type ctrlCArmReset struct{}
// ctrlCArmReset disarms the double-ctrl+c exit window after 1s (D46 exit gesture).
const spinPeriod = 500 * time.Millisecond
// spinTick schedules the next shared-tick frame (the thinking walk,
@@ -595,9 +628,10 @@ type taskDemandMsg struct {
err error
}
// applyTaskDemand folds one answered admission into the transcript:
// the honest receipt — delegated now, queued at position N, or the
// transport refusal — never a silent drop.
// applyTaskDemand folds one answered admission in: work receipts
// render in the TASKS PANE with the queue rows (D44-C: the queued-work
// message NEVER renders in the chat pane); transport refusals stay in
// the chat transcript (the honest error family). Never a silent drop.
func (m *model) applyTaskDemand(msg taskDemandMsg) {
if msg.err != nil {
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
@@ -606,11 +640,9 @@ func (m *model) applyTaskDemand(msg taskDemandMsg) {
}
switch {
case msg.res.Delegated:
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleSystem,
Content: "work started: " + msg.dem})
m.face.SetQueueNotice("work started: " + msg.dem)
case msg.res.Queued:
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleSystem,
Content: fmt.Sprintf("work queued (#%d): %s", msg.res.Position, msg.dem)})
m.face.SetQueueNotice(fmt.Sprintf("work queued (#%d): %s", msg.res.Position, msg.dem))
default:
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
Content: "work demand neither delegated nor queued — the engine answered without a verdict"})
@@ -702,13 +734,13 @@ func (m *model) applyConsentEnqueue(msg consentEnqueueMsg) {
started := msg.res.Delegated || msg.res.Action == "started"
switch {
case started:
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleSystem,
Content: fmt.Sprintf("work started from chat (brief %s · context: %s): %s",
msg.brief.SnapshotID, refs, msg.brief.BriefText)})
m.face.SetQueueNotice(fmt.Sprintf("work started from chat (brief %s · context: %s): %s",
msg.brief.SnapshotID, refs, msg.brief.BriefText))
case queued:
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleSystem,
Content: fmt.Sprintf("work queued from chat (#%d · brief %s · context: %s): %s",
msg.res.Position, msg.brief.SnapshotID, refs, msg.brief.BriefText)})
// D44-C: the queued-work receipt renders in the TASKS PANE
// with the queue rows — never the chat pane.
m.face.SetQueueNotice(fmt.Sprintf("work queued from chat (#%d · brief %s · context: %s): %s",
msg.res.Position, msg.brief.SnapshotID, refs, msg.brief.BriefText))
default:
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
Content: "consented work neither delegated nor queued — the engine answered without a verdict"})
@@ -756,7 +788,10 @@ func (m *model) dispatchSend(sent string) tea.Cmd {
m.chatSt = "sending"
m.pendingSent = sent
m.turnStart = time.Now()
m.face.SetThinkingSpin(true, m.spin)
// D43 addendum 6 item 1: the thinking animation moved to the
// bridge — the shell arms the bridge (which suppresses the chat
// pane's live bubble); the spin frame still rides the shared tick.
m.face.SetThinkingBridge(true, m.spin)
return tea.Batch(chatDispatch(*m, sent), m.armTick())
}
@@ -767,7 +802,7 @@ func (m *model) dispatchSend(sent string) tea.Cmd {
func (m *model) applyChatReport(msg chatCompletionMsg) {
m.chatBusy = false
m.face.SetStill(false) // D43 arbitration: the rail cluster resumes
m.face.SetThinkingSpin(false, 0)
m.face.SetThinkingBridge(false, 0)
if msg.rep.Err != nil {
m.chatSt = "error"
m.face.DropChatThinking()
@@ -939,8 +974,14 @@ func (m *model) syncChatRows(rows []lib.ConvRow) {
// history path rides the row field — a row carrying thinking renders
// its collapsed thinking bubble FIRST, above its own bubble (the
// durable id + "/thinking" keeps the pair stable and clickable across
// polls).
// polls). D46 addendum item 2 (the pane-doctrine boundary): TOOL rows
// project to NOTHING — tool events live in the tasks pane's work
// stream (workblock reads the same wire), the chat transcript stays
// conversational only.
func rowBubbles(r lib.ConvRow) []chatpane.Message {
if r.Role == "tool" {
return nil
}
if r.Thinking == "" {
return []chatpane.Message{{ID: r.MsgID, Role: r.Role, Content: r.Content, Ts: r.Ts}}
}
@@ -1841,6 +1882,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// the runtime answered a task command: the face applies the
// receipt (the authority) or surfaces the refusal honestly.
m.face.ApplyReceipt(msg.asTaskReceipt())
return m, sendSteerCommand(m)
case steerReceiptMsg:
// the runtime answered one queue-steer mutation (D44-D): the
// notice names the verdict; the queue rows re-derive on the
// next poll (the poller is the authority).
m.face.ApplySteerReceipt(msg.cmd.Action, msg.cmd.EntryID, msg.err)
return m, nil
case tea.PasteMsg:
// bracketed paste: the composer owns pasted text while active
@@ -1852,7 +1899,36 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
// THE EXIT GESTURE (owner ruling D46 addendum): the FIRST
// ctrl+c clears the composer draft; the SECOND in succession
// quits. A deliberate double-press — typing can never exit.
if m.lastCtrlC {
return m, tea.Quit
}
nm := m
nm.lastCtrlC = true
if m.composer != nil {
nm.composer.SetText("")
nm.face.SetComposer(composerView(nm.composer), nm.composer.Placeholder(), nm.composer.Hint(nm.km))
}
return nm, tea.Tick(time.Second, func(time.Time) tea.Msg {
return ctrlCArmReset{}
})
}
if _, ok := any(msg).(ctrlCArmReset); ok {
if m.lastCtrlC {
nm := m
nm.lastCtrlC = false
return nm, nil
}
return m, nil
}
{ // any non-ctrl+c key disarms the double-press window
if m.lastCtrlC {
nm := m
nm.lastCtrlC = false
m = nm
}
}
// ctrl+p opens/dismisses the command palette (D34 slice 4:
// the searchable full list beside the '/' input-line road — a
@@ -1872,6 +1948,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.face.ToggleChatThinking()
return m, nil
}
// THE QUEUE STEER (D44-D): ctrl+q opens steering on the
// pending queue (reorder/prioritise/cancel) — a control key
// per the D36 letter law (plain letters type), reachable while
// composing. The tree region hosts the view.
if msg.String() == "ctrl+q" {
if m.face.SteerOpen() {
m.face.SetSteerOpen(false)
return m, nil
}
m.face.SetTreeOpen(true)
m.face.SetSteerOpen(true)
return m, nil
}
// ctrl+g raises/lowers the GAUGES window (D41: progress is a
// callable overlay — never embedded in the task pane); esc
// closes it (handled before anything else can claim the key)
@@ -2147,7 +2236,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
m.syncFocusSignage(focusBefore)
return m, sendTaskCommand(m)
// D43 addendum 6 item 3: exiting chrome back to the composer
// must re-arm the heartbeat (it idles while chrome owns keys);
// otherwise the waiting cursor never resumes blinking.
if cmd := m.armTick(); cmd != nil {
return m, tea.Batch(sendTaskCommand(m), sendSteerCommand(m), cmd)
}
return m, tea.Batch(sendTaskCommand(m), sendSteerCommand(m))
case spinTickMsg:
// D43.14: the composer cursor's blink rides the same heartbeat
// (only while the ghost row is on screen); reduced motion
@@ -2173,10 +2268,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.scaffoldAnimating() {
m.scaffoldLineFrame()
} else if !m.chatBusy {
m.face.SetThinkingSpin(false, 0)
m.face.SetThinkingBridge(false, 0)
} else {
m.spin++
m.face.SetThinkingSpin(true, m.spin)
m.face.SetThinkingBridge(true, m.spin)
}
m.face.TickScroll() // the scrollbar layer's fade (D43 layer law)
m.syncWaves()
@@ -2193,9 +2288,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.face.SetWaveFrame(m.waveFrame)
}
}
// D43 addendum 6 item 3 (the blink regression): the heartbeat
// stays armed whenever the composer can own the next key — a
// draft (heartbeat idle) used to stop the tick, and once it
// stopped NOTHING re-armed it when the box emptied again, so
// the waiting cursor froze on its last phase. Always armed
// while waiting for entry; the blink itself still only moves
// while the ghost row shows.
if m.scaffoldAnimating() || m.chatBusy || m.gaugesOpen ||
m.waveKind == string(factorywave.Running) ||
(m.ghostVisible() && !m.reducedMotion) {
(m.composerActive() && !m.reducedMotion) {
if cmd := m.armTick(); cmd != nil {
return m, cmd
}
@@ -2222,6 +2324,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
// D43 addendum 6 item 5: the info bar's "waves N" opener is
// the shell's own clickable — it lives BELOW the face content
// (the last row), outside the face chain's regions. Resolve it
// FIRST: a click on the token opens the sub-agent tab (and
// focuses the tasks pane so the tab is visible).
if m.face.WaveCount() > 0 && msg.Y == m.face.Snapshot().H {
prefix := semantic.StripANSI(versionToken(m))
x0 := len([]rune(prefix))
tok := semantic.StripANSI(wavesToken(m))
tw := len([]rune(tok))
if msg.X >= x0 && msg.X < x0+tw {
m.face.Apply(semantic.Rune('2')) // focus the tasks pane
m.face.SetTreeOpen(true)
m.face.OpenWaveTab()
return m, nil
}
}
// the selection law's mouse leg (D29/TD-4): a click resolves
// through the face chain's own regions — the shell adds no
// click semantics of its own, exactly as it adds no key
@@ -2243,12 +2362,14 @@ func (m model) View() tea.View {
var b strings.Builder
b.WriteString(content)
b.WriteString("\n")
// D43.11/12: the info bar — the ONLY chrome row: the semver
// version and the engine's context-window readout. Nothing else
// (running is proven by working; focus is visible in the
// selection; the provider and connection state live where they
// are real — the picker menu and the transcript).
fmt.Fprintf(&b, "rune %s%s", semverOf(m.version), ctxToken(m))
// D43.11/12 + D43 addendum 6 items 5/6 + D44-A: the info bar — the
// ONLY chrome row. The semver (v-prefixed, OSC 8 hyperlinked to the
// Gitea release, degrading to plain text where the terminal lacks
// OSC 8 — detect-then-render), the sub-agent opener "waves N" after
// the version (hidden at zero, clickable to open the sub-agent tab),
// the compacted active model (D44-A: local truncated name+size+style-
// if-applicable+ablation; cloud full), and the context window.
fmt.Fprintf(&b, "%s%s%s%s", versionToken(m), wavesToken(m), modelToken(m), ctxToken(m))
v := tea.NewView(b.String())
// ALTERNATE SCREEN LAW (owner ruling 2026-10-19: on exit the TUI
// must DISAPPEAR — the terminal restores the pre-boot command line,
@@ -2296,6 +2417,281 @@ func ctxToken(m model) string {
return fmt.Sprintf(" · context %s/%s", compactTokens(used), compactTokens(limit))
}
// versionToken renders the info bar's version segment (D43 addendum 6
// item 6): "rune v0.321.0" with a v-prefix and an OSC 8 hyperlink to the
// Gitea release. DETECT-then-RENDER (boundary law): the OSC 8 wrap is
// emitted only when the terminal is known to support it; otherwise the
// plain-text "rune v0.321.0" degrades gracefully (the link is absent,
// the text identical). A dev-form version (dev-g<sha>) still carries
// the v-prefix but links to the repo's releases page (no exact tag).
func versionToken(m model) string {
ver := "v" + semverOf(m.version)
if !linkVersions() {
return "rune " + ver
}
// the OSC 8 wrap is emitted DIRECTLY (not through lipgloss): the
// sequence's presence must not depend on the style renderer's
// terminal-profile detection — linkVersions IS the detection
// (detect-then-render), and the cell parser carries the link.
return "rune " + "\x1b]8;;" + releaseURL(m.version) + "\x1b\\" + ver + "\x1b]8;;\x1b\\"
}
// wireOSCDetection installs the real terminal detection for the
// version hyperlink (D43 addendum 6 item 6, detect-then-render):
// Konsole (the owner's terminal), iTerm2, WezTerm, Ghostty, kitty,
// foot and tmux/screen pass-through are known OSC 8 carriers; an
// unknown terminal keeps plain text. Only the real main() calls this —
// hermetic tests keep the graceful degrade.
func wireOSCDetection() {
linkVersions = func() bool {
if v := os.Getenv("KONSOLE_VERSION"); v != "" {
return true
}
switch os.Getenv("TERM_PROGRAM") {
case "konsole", "Konsole", "iTerm.app", "WezTerm", "ghostty", "vscode":
return true
}
switch t := os.Getenv("TERM"); t {
case "xterm-kitty", "foot", "wezterm":
return true
}
return strings.HasPrefix(os.Getenv("TERM"), "tmux") ||
strings.HasPrefix(os.Getenv("TERM"), "screen")
}
}
// wavesToken renders the sub-agent opener (D43 addendum 6 item 5):
// " · waves N" placed AFTER the version token, hidden when the flight
// count is zero (the tab exists only while waves fly). The token is
// the info bar's clickable opener for the sub-agent tab/view.
func wavesToken(m model) string {
n := m.face.WaveCount()
if n <= 0 {
return ""
}
return fmt.Sprintf(" · waves %d", n)
}
// modelToken renders the compacted active model (D44-A): the model the
// focused pane runs, parsed intelligently so a 70-char local filename
// never eats the bar. LOCAL models truncate to name + size + style (a
// classifier marker, only when applicable) + ablation flag; CLOUD
// models keep their full name + size + classifier-if-applicable.
func modelToken(m model) string {
if m.prov == nil {
return ""
}
model := m.prov.paneModel(m.focusedPane())
if model == "" {
model = m.prov.Model
}
if model == "" {
return ""
}
return " · " + compactModel(m.prov.Provider, model)
}
// linkVersions gates the OSC 8 hyperlink on the version token. The
// DEFAULT is plain text — hermetic tests and offline faces degrade
// gracefully by construction; only the REAL main() wires the terminal
// detection in (detect-then-render: the link is emitted only where the
// terminal is known to honor OSC 8).
var linkVersions = func() bool { return false }
// giteaReleaseBase is the Gitea base URL for release links (the same
// server the update check and the forge read; GITEA_URL overrides).
const giteaReleaseBase = "http://127.0.0.1:3000"
// giteaRepo is the repo path on the Gitea server (forge.DefaultRepo).
const giteaRepo = "conductor/rune"
// releaseURL builds the Gitea release URL for a version. A semver core
// (three numeric components — including the describe form 0.321.0-56-g…,
// whose core is the tag) links to its tui-v tag release; a pure dev form
// (dev-g<sha>) has no tag and links to the releases list.
func releaseURL(ver string) string {
base := os.Getenv("GITEA_URL")
if base == "" {
base = giteaReleaseBase
}
sv := semverOf(ver)
if isSemverCore(sv) {
return fmt.Sprintf("%s/%s/releases/tag/tui-v%s", base, giteaRepo, sv)
}
return fmt.Sprintf("%s/%s/releases", base, giteaRepo)
}
// isSemverCore reports whether s is a full semver (d.d.d) — a version
// identity that names a real tag.
func isSemverCore(s string) bool {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
for _, c := range p {
if c < '0' || c > '9' {
return false
}
}
}
return true
}
// compactModel parses a model id into the D45-refined info-bar form:
// BASE NAME + PARAM SIZE + ABLATION MARKER only — "qwen3.6 27B ·
// uncensored". Everything between the size marker and the marker is
// marketing noise (Fable-Fusion-711-Heretic-NM-DAU-NEO-MAX-MTP) and is
// dropped; hf.co/ prefixes, org paths (DavidAU/), -GGUF tails and
// :QUANT tags never render. A bare size tag IS the size (qwen2.5:7b).
// CLOUD ids keep vendor-cased full name + size when present.
func compactModel(provider, model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
cloud := isCloudProvider(provider)
// strip a leading hf.co/ or huggingface.co/ repo prefix
short := model
stripped := false
for _, pfx := range []string{"hf.co/", "huggingface.co/"} {
if strings.HasPrefix(short, pfx) {
short = strings.TrimPrefix(short, pfx)
stripped = true
break
}
}
// drop the owner/ segment before the model name (e.g. bartowski/Name)
if i := strings.IndexByte(short, '/'); i >= 0 {
short = short[i+1:]
stripped = true
}
// a :QUANT tail: a bare size tag (7b, 0.5b) is the SIZE; anything
// else (IQ3_M, latest, Q4_K_M) is dropped
size := ""
if i := strings.IndexByte(short, ':'); i >= 0 {
tag := short[i+1:]
if s, ok := bareSizeTag(tag); ok {
size = s
}
short = short[:i]
}
// walk the dash tokens: everything up to the size marker is the
// base name; after it, only ablation/classifier markers survive
tokens := strings.FieldsFunc(short, func(r rune) bool { return r == '-' || r == '_' })
base := tokens
for i, tok := range tokens {
if s, ok := bareSizeTag(tok); ok {
if size == "" {
size = s
}
base = tokens[:i]
tokens = tokens[i+1:]
break
}
}
marker, classifier := "", ""
for _, tok := range tokens {
low := strings.ToLower(tok)
switch {
case low == "uncensored" && marker == "":
marker = "uncensored"
case low == "abliterated" && marker == "":
marker = "abliterated"
case low == "heretic" && marker == "":
marker = "heretic"
case low == "classifier":
classifier = "classifier"
}
}
// -GGUF and quant suffix tokens are never part of the base
for len(base) > 0 {
last := strings.ToLower(base[len(base)-1])
if last == "gguf" || isQuantTag(last) {
base = base[:len(base)-1]
continue
}
break
}
baseName := strings.Join(base, "-")
if baseName == "" {
baseName = short
}
// nothing parsed (no size, no markers, no stripped prefix/path):
// the id was already compact — show it VERBATIM (the honest no-op,
// :latest tag included), never a mangled half-strip
if size == "" && marker == "" && classifier == "" && !stripped {
return model
}
if !cloud {
baseName = strings.ToLower(baseName)
}
out := baseName
if size != "" {
out += " " + size
}
if classifier != "" {
out += " · classifier"
}
if marker != "" {
out += " · " + marker
}
// cap so the model never eats the bar
if r := []rune(out); len(r) > 32 {
out = string(r[:31]) + "…"
}
return out
}
// bareSizeTag reports whether a whole token is a parameter size (7B,
// 27B, 0.5B, 14b...) and returns its display form (uppercased unit).
func bareSizeTag(tok string) (string, bool) {
t := strings.TrimSpace(tok)
if t == "" || len(t) > 6 {
return "", false
}
i := 0
for i < len(t) && (t[i] >= '0' && t[i] <= '9' || t[i] == '.') {
i++
}
if i == 0 || i == len(t) {
return "", false
}
unit := t[i:]
if unit != "b" && unit != "B" && unit != "m" && unit != "M" {
return "", false
}
return t[:i] + strings.ToUpper(unit), true
}
// isQuantTag reports whether a token is a GGUF quantization tag
// (IQ3_M, Q4_K_M, Q8_0, ...).
func isQuantTag(tok string) bool {
if len(tok) < 3 || (tok[0] != 'q' && tok[0] != 'Q' && tok[0] != 'i' && tok[0] != 'I') {
return false
}
for _, r := range tok[1:] {
if (r < '0' || r > '9') && r != '_' && r != 'K' && r != 'M' && r != 'S' && r != 'L' && (r < 'A' || r > 'Z') {
return false
}
}
return true
}
// isCloudProvider reports whether the provider is a cloud backend (vs
// the local Ollama runner) — the D44-A local/cloud display split.
func isCloudProvider(provider string) bool {
switch strings.ToLower(provider) {
case "openai", "anthropic", "google", "gemini", "mistral", "groq",
"together", "fireworks", "deepseek", "openrouter", "cloud":
return true
}
return false
}
// compactTokens renders a token count the info bar's way: 4200 →
// "4.2k", 32768 → "32k" (one decimal below 10k, none above).
func compactTokens(n int64) string {
@@ -2667,6 +3063,7 @@ func main() {
printBuildID(os.Stdout)
return
}
wireOSCDetection() // the version hyperlink's real-terminal detection
bootCheck = update.DefaultCheck
// D38: the ledger section reads the task markdown ledger through
// the library seam — the same directory the serve surface
@@ -2700,3 +3097,4 @@ func main() {
os.Exit(1)
}
}
+77 -50
View File
@@ -88,15 +88,27 @@ func TestInitDoesNothing(t *testing.T) {
}
}
func TestQuitOnQ(t *testing.T) {
if !quits(t, initialModel(), keyPress("q")) {
t.Fatal("pressing q does not quit")
// D46 addendum: q is a letter the composer owns — it must NEVER quit.
func TestQDoesNotQuit(t *testing.T) {
if quits(t, initialModel(), keyPress("q")) {
t.Fatal("pressing q quit — q must type, never exit")
}
}
func TestQuitOnCtrlC(t *testing.T) {
if !quits(t, initialModel(), ctrlKey("c")) {
t.Fatal("pressing ctrl+c does not quit")
// TestQuitOnDoubleCtrlC: the exit gesture (owner ruling D46 addendum) —
// the FIRST ctrl+c clears the composer; the SECOND in succession quits.
func TestQuitOnDoubleCtrlC(t *testing.T) {
nx, _ := initialModel().Update(ctrlKey("c"))
if quits(t, nx.(model), ctrlKey("c")) {
return // second press within the window quit: correct
}
t.Fatal("double ctrl+c does not quit")
}
// TestSingleCtrlCDoesNotQuit: one press only clears — never exits.
func TestSingleCtrlCDoesNotQuit(t *testing.T) {
if quits(t, initialModel(), ctrlKey("c")) {
t.Fatal("a single ctrl+c quit — the exit gesture needs two in succession")
}
}
@@ -135,11 +147,12 @@ func TestViewShowsTheTwoLanes(t *testing.T) {
func TestViewShowsHonestIdentity(t *testing.T) {
m := sized(t, initialModel(), 80, 24)
got := m.View().Content
// D43.11/12: the info bar carries ONLY the semver version (+ the
// context readout when the engine reported one) — every other
// D43.11/12 + addendum 6 item 6: the info bar carries the semver
// with the v-prefix (v0.321.0 — the owner's version marker) + the
// context readout when the engine reported one — every other
// token left the line with the messy far-bottom bar.
if !strings.Contains(got, "rune "+semverOf(Version)) {
t.Errorf("info bar missing the semver version:\n%s", lastLine(got))
if !strings.Contains(got, "rune v"+semverOf(Version)) {
t.Errorf("info bar missing the v-prefixed semver:\n%s", lastLine(got))
}
for _, banned := range []string{"status=", "focus=", "seq=", "RAIL |", "runtime lib:", "runtime serve:"} {
if strings.Contains(got, banned) {
@@ -316,10 +329,10 @@ func TestShellReservesItsIdentityLine(t *testing.T) {
if len(lines) != 24 {
t.Errorf("view has %d lines, want exactly 24 (panes + shared input box + the D43 info bar)", len(lines))
}
// D43.11/12: the last row is the info bar (version [+ ctx]); the
// rail row is gone from the home.
if !strings.Contains(lines[len(lines)-1], "rune "+semverOf(Version)) {
t.Errorf("info bar must carry the semver version, got %q", lines[len(lines)-1])
// D43.11/12 + addendum 6 item 6: the last row is the info bar
// (v-prefixed version [+ ctx]); the rail row is gone from the home.
if !strings.Contains(lines[len(lines)-1], "rune v"+semverOf(Version)) {
t.Errorf("info bar must carry the v-prefixed semver, got %q", lines[len(lines)-1])
}
if strings.Contains(strings.Join(lines, "\n"), "RAIL |") {
t.Errorf("D43.12: the home must not carry the RAIL row:\n%s", lines[len(lines)-3])
@@ -384,7 +397,7 @@ func TestPromoteRegionWalk(t *testing.T) {
if !strings.Contains(got, "PROMOTION SUGGESTIONS") {
t.Fatalf("p did not surface the suggestion region:\n%s", got)
}
for _, poke := range []string{"q", "r", "y"} {
for _, poke := range []string{"z", "r", "y"} { // q types now; the exit gesture is ctrl+c twice (tested separately)
next, cmd := m.Update(keyPress(poke))
if cmd != nil {
t.Fatalf("key %q inside the region produced a command (must be swallowed)", poke)
@@ -401,8 +414,10 @@ func TestPromoteRegionWalk(t *testing.T) {
if !strings.Contains(got, "QUEUED FOR TASK (1)") {
t.Fatalf("queued hold not visible after backing out:\n%s", got)
}
if !quits(t, closed.(model), keyPress("q")) {
t.Fatal("q does not quit after the region closed — the boundary leaks")
// the exit gesture after the region closed: the double-ctrl+c fires
nx, _ := closed.(model).Update(ctrlKey("c"))
if !quits(t, nx.(model), ctrlKey("c")) {
t.Fatal("the double-ctrl+c exit does not fire after the region closed — the boundary leaks")
}
}
@@ -1423,10 +1438,13 @@ func (f *thinkingFake) Complete(context.Context, lib.Completion) (lib.Completion
}
// TestThinkingAnimationWhileDispatchInFlight: while a dispatch runs,
// the identity line carries the frame-cycled diamond and the live
// thinking bubble shows the animated marker; a tick advances the frame
// (Bubble Tea timer discipline: tea.Tick messages, never sleeps in
// Update); completion clears both honestly.
// the THINKING BRIDGE (D43 addendum 6 item 1 — the animation moved out
// of the chat pane into its own full-width strip between the panes and
// the composer) shows the animated marker and the chat pane renders
// CONVERSATION-ONLY (no live thinking bubble); a tick advances the
// frame (Bubble Tea timer discipline: tea.Tick messages, never sleeps
// in Update); completion extinguishes the bridge honestly.
// Supersedes the D43.13 chat-bubble pin: the law moved the animation.
func TestThinkingAnimationWhileDispatchInFlight(t *testing.T) {
configuredHome(t)
release := make(chan struct{})
@@ -1450,10 +1468,14 @@ func TestThinkingAnimationWhileDispatchInFlight(t *testing.T) {
t.Fatal("dispatch must be in flight")
}
// the tick rides the same batch as the dispatch; the walk animates
// in the THINKING BUBBLE (D43.13 — the identity line is gone)
// in the BRIDGE STRIP (D43 addendum 6 item 1) and the chat pane
// stays conversation-only
frame := func() string { return semantic.StripANSI(m.face.Render().Content) }
if !strings.Contains(frame(), "◆ thinking · ctrl+t expands · ") {
t.Fatalf("live thinking bubble missing the D43.13 line shape:\n%s", frame())
if !strings.Contains(frame(), "◆ thinking · click expands · ") {
t.Fatalf("thinking bridge missing its live line shape:\n%s", frame())
}
if strings.Contains(frame(), "◆ thinking · ctrl+t expands") {
t.Fatalf("the live thinking bubble must NOT render in the chat pane (bridge owns it):\n%s", frame())
}
g0 := anyVocabGlyph(frame())
if g0 == "" {
@@ -1509,6 +1531,9 @@ func TestThinkingAnimationWhileDispatchInFlight(t *testing.T) {
if g := anyVocabGlyph(v); g != "" {
t.Fatalf("spinner still visible after completion (%q):\n%s", g, v)
}
if strings.Contains(semantic.StripANSI(v), "◆ thinking · click expands") {
t.Fatalf("the thinking bridge must self-extinguish on completion:\n%s", v)
}
for _, msg := range m.face.ChatSnapshot() {
if msg.Role == "thinking" && msg.Content == "" {
t.Fatal("the pending thinking bubble must be amended or dropped, never left empty")
@@ -1596,14 +1621,13 @@ func TestThinkingContentSurfacedFromRuneBlock(t *testing.T) {
t.Fatalf("thinking content = %q, want the surfaced thought text", think)
}
frame := semantic.StripANSI(m.face.Render().Content)
if !strings.Contains(frame, "thinking (2 lines)") {
t.Fatalf("collapsed thinking summary missing:\n%s", frame)
}
next, _ = m.Update(ctrlKey("t"))
m = next.(model)
frame = semantic.StripANSI(m.face.Render().Content)
if !strings.Contains(frame, "step one") {
t.Fatalf("expanded thinking must show the actual thought:\n%s", frame)
// D46 #2 (D43 addendum 6 item 1): the chat pane is CONVERSATION-
// ONLY — no thinking bubble renders in chat, live or completed;
// the bridge strip owns thinking. The thought DATA stays (the
// snapshot above carries it). Supersedes the old in-chat summary
// pin.
if strings.Contains(frame, "thinking (2 lines)") || strings.Contains(frame, "step one") {
t.Fatalf("the chat pane must not render thinking (bridge owns it):\n%s", frame)
}
}
@@ -1635,10 +1659,10 @@ func TestThinkingPlaceholderWhenCoreSurfacesNone(t *testing.T) {
if think != "reasoning not surfaced by core yet" {
t.Fatalf("thinking placeholder = %q", think)
}
next, _ = m.Update(ctrlKey("t"))
m = next.(model)
if frame := semantic.StripANSI(m.face.Render().Content); !strings.Contains(frame, "reasoning not surfaced by core yet") {
t.Fatalf("expanded placeholder bubble must show the honest line:\n%s", frame)
// D46 #2: the chat pane renders NO thinking (the bridge owns it);
// the placeholder's honest DATA survives in the snapshot above.
if frame := semantic.StripANSI(m.face.Render().Content); strings.Contains(frame, "reasoning not surfaced by core yet") {
t.Fatalf("the chat pane must not render thinking:\n%s", frame)
}
}
@@ -1769,19 +1793,18 @@ func TestBootHistoryRendersThinkingBubble(t *testing.T) {
t.Fatalf("row[2] = %+v, want the assistant reply after its thinking", rows[2])
}
frame := semantic.StripANSI(m.face.Render().Content)
if !strings.Contains(frame, "thinking (1 lines)") {
t.Fatalf("collapsed thinking summary missing from the restored transcript:\n%s", frame)
// D46 #2 (D43 addendum 6 item 1): the chat pane is CONVERSATION-
// ONLY — the restored thinking row's DATA stays in the snapshot
// but renders nowhere in chat; the bridge owns thinking. The
// assistant reply renders; the thought does not.
if strings.Contains(frame, "thinking (1 lines)") || strings.Contains(frame, "first I considered") {
t.Fatalf("the chat pane must not render thinking (bridge owns it):\n%s", frame)
}
if strings.Contains(frame, "first I considered") {
t.Fatalf("thinking must render COLLAPSED by default:\n%s", frame)
if !strings.Contains(frame, "the answer") {
t.Fatalf("the assistant reply must still render:\n%s", frame)
}
// clickable per the landed law: ctrl+t expands it
next, _ = m.Update(ctrlKey("t"))
m = next.(model)
if frame := semantic.StripANSI(m.face.Render().Content); !strings.Contains(frame, "first I considered the options") {
t.Fatalf("expanded thinking must show the thought:\n%s", frame)
}
// and a second sync never duplicates the pair
// and a second sync never duplicates the pair (the D46 #1
// no-dupe tripwire stays pinned)
next, _ = m.Update(bridge.EventMsg{})
m = next.(model)
if rows := m.face.ChatSnapshot(); len(rows) != 3 {
@@ -2651,9 +2674,13 @@ func TestTaskFocusTypesAndSendsWorkDemand(t *testing.T) {
}
m2, _ = m.Update(msg)
m = m2.(model)
txt := chatBubbleText(m)
if !strings.Contains(txt, "work queued (") || !strings.Contains(txt, "wire the stream") {
t.Fatalf("the honest queue receipt missing:\n%s", txt)
// D44-C: the queued-work receipt renders in the TASKS PANE (the
// tree region, with the queue rows) — NEVER the chat pane.
if got := m.face.QueueNotice(); !strings.Contains(got, "work queued (") || !strings.Contains(got, "wire the stream") {
t.Fatalf("the honest queue receipt missing from the tasks pane, got %q", got)
}
if txt := chatBubbleText(m); strings.Contains(txt, "work queued (") {
t.Fatalf("the queued receipt must NOT render in the chat pane:\n%s", txt)
}
}
+1 -2
View File
@@ -4,7 +4,6 @@
"pins": {
"walk_states": 192,
"actions": [
"app.quit",
"degrade.open",
"degrade.restore",
"degrade.swallow",
@@ -126,4 +125,4 @@
]
}
]
}
}
+144
View File
@@ -0,0 +1,144 @@
// bridge_test.go — THE THINKING BRIDGE frozen suite (boundary-law
// §D43 addendum 6 item 1): the thinking animation's own full-width
// strip between the panes and the composer. The strip exists ONLY
// while thinking runs (self-extinguishing); clicking it expands the
// thought process in place; while armed, the chat pane suppresses its
// live thinking bubble (conversation-only for the active turn).
package lanes
import (
"strings"
"testing"
"time"
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/semantic"
)
func TestBridgeInactiveRendersNothing(t *testing.T) {
l := New(80, 24)
l.SetComposer([]string{}, "type in chat", "hint")
if lines := l.bridgeStrip(80); lines != nil {
t.Fatalf("an inactive bridge must render nothing, got %v", lines)
}
fr := l.Render()
if strings.Contains(fr.Content, "click expands") {
t.Fatal("an inactive bridge must not paint a strip")
}
}
func TestBridgeRendersBetweenPanesAndComposer(t *testing.T) {
l := New(80, 24)
l.SetComposer([]string{}, "type in chat", "hint")
l.AppendChat(chatpane.Message{Role: chatpane.RoleUser, Content: "hello"})
l.SetThinkingBridge(true, 0)
fr := l.Render()
lines := strings.Split(fr.Content, "\n")
stripAt := -1
boxTop := -1
for i, ln := range lines {
if strings.Contains(semantic.StripANSI(ln), "◆ thinking · click expands") && stripAt < 0 {
stripAt = i
}
if strings.HasPrefix(semantic.StripANSI(ln), "┌") && boxTop < 0 && stripAt >= 0 {
boxTop = i
}
}
if stripAt < 0 {
t.Fatalf("the bridge strip must render while thinking runs:\n%s", fr.Content)
}
if boxTop < 0 || boxTop != stripAt+1 {
t.Fatalf("the bridge must sit between the panes and the composer box: strip@%d box@%d:\n%s", stripAt, boxTop, fr.Content)
}
// its click region exists and covers the strip row
found := false
for _, r := range fr.Regions {
if r.ID == "mouse.bridge" && r.Y == stripAt {
found = true
}
}
if !found {
t.Fatal("the bridge must declare its mouse.bridge region on the strip row")
}
}
func TestBridgeSelfExtinguishesAndDisarmsChatBubble(t *testing.T) {
l := New(80, 24)
l.SetComposer([]string{}, "type in chat", "hint")
// a live pending thinking bubble rides the pane's tail
l.AppendChat(chatpane.Message{Role: chatpane.RoleThinking, Content: ""})
if got := l.chat.PendingThinkingText(); got != "" {
t.Fatalf("pending text before amend: %q", got)
}
l.SetThinkingBridge(true, 0)
if lines := l.Render().Content; strings.Contains(semantic.StripANSI(lines), "ctrl+t expands") {
t.Fatalf("the chat pane must suppress its live thinking bubble while the bridge owns it:\n%s", lines)
}
if !l.chat.SuppressLiveThinking() {
t.Fatal("arming the bridge must set the chat pane's suppress flag")
}
l.SetThinkingBridge(false, 0)
if lines := l.bridgeStrip(80); lines != nil {
t.Fatalf("disarm must extinguish the strip, got %v", lines)
}
if l.chat.SuppressLiveThinking() {
t.Fatal("disarm must release the chat pane's suppress flag")
}
}
func TestBridgeClickExpandsThoughtInPlace(t *testing.T) {
l := New(80, 24)
l.SetComposer([]string{}, "type in chat", "hint")
l.AppendChat(chatpane.Message{Role: chatpane.RoleThinking, Content: ""})
if !l.AmendChatThinking("step one: read the file\nstep two: write the test") {
t.Fatal("amend must fill the pending bubble")
}
l.SetThinkingBridge(true, 0)
// find the strip's region and click it
fr := l.Render()
var bx, by int = -1, -1
for _, r := range fr.Regions {
if r.ID == "mouse.bridge" {
bx, by = r.X, r.Y
}
}
if bx < 0 {
t.Fatal("no mouse.bridge region")
}
if got := l.Click(bx, by); got != semantic.ActionID("mouse.bridge.expand") {
t.Fatalf("clicking the bridge must toggle expansion, got %q", got)
}
after := semantic.StripANSI(l.Render().Content)
if !strings.Contains(after, "step two: write the test") {
t.Fatalf("the expanded bridge must surface the thought text:\n%s", after)
}
// clicking again collapses
if got := l.Click(bx, by); got != semantic.ActionID("mouse.bridge.expand") {
t.Fatalf("second click must collapse, got %q", got)
}
if again := semantic.StripANSI(l.Render().Content); strings.Contains(again, "step two: write the test") {
t.Fatalf("collapsed bridge must hide the thought text:\n%s", again)
}
}
func TestBridgeStampFarLeftOnLiveStrip(t *testing.T) {
// D43 addendum 6 item 2 rides the bridge too: a ts on the live
// thinking strip leads at column 0 (local render of the UTC
// record, chatpane.shortTs law).
l := New(80, 24)
l.SetComposer([]string{}, "type in chat", "hint")
l.AppendChat(chatpane.Message{Role: chatpane.RoleThinking, Content: "", Ts: "2026-10-20T10:00:00Z"})
l.SetThinkingBridge(true, 0)
expect, _ := time.Parse(time.RFC3339, "2026-10-20T10:00:00Z")
want := expect.Local().Format("15:04") + " ◆"
fr := semantic.StripANSI(l.Render().Content)
for _, ln := range strings.Split(fr, "\n") {
if strings.Contains(ln, "click expands") {
if !strings.HasPrefix(ln, want) {
t.Fatalf("the bridge strip's stamp must lead at column 0: want prefix %q got %q", want, ln)
}
return
}
}
t.Fatalf("no live strip found:\n%s", fr)
}
+49
View File
@@ -65,6 +65,44 @@ type Lanes struct {
// addendum: BOTH panes scroll-fade) — lights on task-cursor
// motion, decays on the shared tick. Display state, never semantic.
taskScrollFade int
// THE THINKING BRIDGE (D43 addendum 6 item 1): the thinking
// animation's own full-width strip BETWEEN the panes and the
// composer. It exists ONLY while thinking runs (self-extinguishing
// like every animation law); clicking the strip expands the thought
// process in-place. Display state, never semantic.
bridgeActive bool
bridgeFrame int
bridgeExpanded bool
}
// SetThinkingBridge arms/disarms the thinking bridge and sets its
// animation frame (the shell's shared tick drives it). Arming the
// bridge also suppresses the chat pane's live thinking bubble — the
// chat declutters to conversation-only for the active turn (D43
// addendum 6 item 1). Disarming clears the expansion (the strip
// self-extinguishes whole).
func (l *Lanes) SetThinkingBridge(active bool, frame int) {
l.bridgeActive = active
l.bridgeFrame = frame
if !active {
l.bridgeExpanded = false
}
if l.chat != nil {
l.chat.SetSuppressLiveThinking(active)
}
}
// BridgeActive reports whether the thinking bridge strip is showing.
func (l *Lanes) BridgeActive() bool { return l.bridgeActive }
// ToggleBridgeExpand flips the bridge's in-place thought expansion
// (clicking the strip); reports whether it flipped.
func (l *Lanes) ToggleBridgeExpand() bool {
if !l.bridgeActive {
return false
}
l.bridgeExpanded = !l.bridgeExpanded
return true
}
// New returns the two-lane face on the canonical semantic seed.
@@ -309,6 +347,14 @@ func (l *Lanes) Click(x, y int) semantic.ActionID {
return semantic.ActionID(r.ID)
}
}
// the thinking bridge's click (D43 addendum 6 item 1): the
// whole strip toggles the in-place thought expansion.
if r.ID == "mouse.bridge" {
if l.ToggleBridgeExpand() {
return semantic.ActionID("mouse.bridge.expand")
}
return ""
}
}
for _, r := range fr.Regions {
if r.ID == "region.chat" && inRegion(r, x, y) {
@@ -446,6 +492,9 @@ func (l *Lanes) Clone() semantic.Face {
n.card = l.card
n.hints = append([]string(nil), l.hints...)
n.cursorOn = l.cursorOn
n.bridgeActive = l.bridgeActive
n.bridgeFrame = l.bridgeFrame
n.bridgeExpanded = l.bridgeExpanded
return &n
}
+60 -43
View File
@@ -465,8 +465,9 @@ func TestMenuRendersInBandAboveBox(t *testing.T) {
// TestChatInputBoxGrowsWhenTextWraps: the box holds its 2 GENEROUS
// resting rows at rest (D41 addendum 2: blank pad + placeholder — an
// obvious place to type) and grows toward 5 text rows as the text
// wraps — never a transient thin line. The panes shrink by the grown
// rows (D36: the band owns the bottom).
// wraps (D46 item 3: the composer GROWS, never shrinks) — never a
// transient thin line. The panes shrink by the grown rows (D34: the
// panes YIELD to composer growth — both panes push up).
func TestChatInputBoxGrowsWhenTextWraps(t *testing.T) {
l := composerLanes(t)
restFrame := l.Render()
@@ -479,13 +480,20 @@ func TestChatInputBoxGrowsWhenTextWraps(t *testing.T) {
grownFrame := l.Render()
wrapped := regionOf(t, grownFrame, "region.input")
grownChat := regionOf(t, grownFrame, "region.chat")
if wrapped.H < 5 || wrapped.H > 7 { // word-safe wrap: the box windows its rows (≤3 text rows + pads, no hint)
t.Fatalf("wrapped box holds H=%d, want 5..7 (word-safe windowed rows, hint gone)", wrapped.H)
if wrapped.H < 5 || wrapped.H > 8 { // word-safe wrap: ≤5 text rows + pad + 2 borders
t.Fatalf("wrapped box holds H=%d, want 5..8 (grow toward 5 text rows — D46)", wrapped.H)
}
if grownChat.H != restChat.H-(wrapped.H-rest.H) {
t.Fatalf("panes must shrink by the box's grown rows: chat %d -> %d with box %d -> %d",
restChat.H, grownChat.H, rest.H, wrapped.H)
}
// D46 item 3: the composer may demand FIVE text rows when the wrap
// needs them (the old 3-row cap starved it — the owner's shrink)
l.SetComposer([]string{strings.Repeat("word ", 130)}, "", "hint")
five := regionOf(t, l.Render(), "region.input")
if five.H != 8 { // 2 borders + pad + 5 text rows
t.Fatalf("a five-row wrap must hold H=8, got %d (the composer grows, D46)", five.H)
}
}
// TestHintsRenderAboveInputBox: the '/'-command hint overlay renders
@@ -518,44 +526,64 @@ func TestHintsRenderAboveInputBox(t *testing.T) {
// TestThinkingBubbleClickToggles: clicking a thinking bubble's line
// region expands THAT bubble (the D35 clickable control); a second
// click collapses it. The action id is honest about what happened.
// TestThinkingBubbleClickToggles — SUPERSEDED by D46 item 2: the chat
// pane renders no thinking, so no chat-bubble control regions exist.
// The BRIDGE strip's click-to-expose replaces them (pinned below).
func TestThinkingBubbleClickToggles(t *testing.T) {
l := composerLanes(t)
l.AppendChat(chatMessage("t1", "thinking", "secret reasoning"))
fr := l.Render()
for _, r := range fr.Regions {
if strings.HasPrefix(r.ID, "mouse.chat.think.") {
t.Fatalf("no chat thinking regions may exist (D46 bridge move): %+v", r)
}
}
if strings.Contains(semantic.StripANSI(fr.Content), "secret reasoning") {
t.Fatal("thinking must not render in the chat pane (D46)")
}
}
// TestBridgeStripExposesThoughtOnClick — D46 item 2's exposure law:
// while the bridge is armed (a turn is thinking), clicking the strip
// expands the pending thought in-place; a second click collapses; the
// strip self-extinguishes when disarmed.
func TestBridgeStripExposesThoughtOnClick(t *testing.T) {
l := composerLanes(t)
l.AppendChat(chatpane.Message{Role: chatpane.RoleUser, Content: "q"})
l.AppendChat(chatpane.Message{Role: chatpane.RoleThinking, Content: ""})
l.AmendChatThinking("the surfaced reasoning")
l.SetThinkingBridge(true, 1)
fr := l.Render()
body := semantic.StripANSI(fr.Content)
if !strings.Contains(body, "thinking · click expands") {
t.Fatalf("the bridge strip must render above the composer:\n%s", body)
}
if strings.Contains(body, "the surfaced reasoning") {
t.Fatal("collapsed bridge must not show the thought yet")
}
var reg semantic.Region
for _, r := range fr.Regions {
if strings.HasPrefix(r.ID, "mouse.chat.think.") {
if r.ID == "mouse.bridge" {
reg = r
break
}
}
if reg.ID == "" {
t.Fatal("no thinking bubble region in the frame")
t.Fatal("no bridge click region")
}
if a := l.Click(reg.X, reg.Y); !strings.HasPrefix(string(a), "mouse.chat.think.") {
t.Fatalf("click action = %q, want the thinking-toggle id", a)
if a := l.Click(reg.X, reg.Y); a != "mouse.bridge.expand" {
t.Fatalf("bridge click action = %q", a)
}
body := semantic.StripANSI(l.Render().Content)
if !strings.Contains(body, "secret reasoning") {
t.Fatalf("click must expand the thinking bubble:\n%s", body)
}
// the bottom-up anchor moves the bubble's row when its body grows —
// a real clicker aims at the CURRENT screen, so re-capture the
// region before the second click
fr = l.Render()
reg.ID = ""
for _, r := range fr.Regions {
if strings.HasPrefix(r.ID, "mouse.chat.think.") {
reg = r
break
}
}
if reg.ID == "" {
t.Fatal("thinking region vanished after expansion")
if body = semantic.StripANSI(l.Render().Content); !strings.Contains(body, "the surfaced reasoning") {
t.Fatalf("the click must expose the thought process:\n%s", body)
}
l.Click(reg.X, reg.Y)
if strings.Contains(semantic.StripANSI(l.Render().Content), "secret reasoning") {
t.Fatal("second click must collapse the bubble again")
if strings.Contains(semantic.StripANSI(l.Render().Content), "the surfaced reasoning") {
t.Fatal("second click must collapse the exposure")
}
// self-extinguish: the disarm clears the whole strip
l.SetThinkingBridge(false, 0)
if body = semantic.StripANSI(l.Render().Content); strings.Contains(body, "thinking · click expands") {
t.Fatalf("the disarmed bridge must vanish:\n%s", body)
}
}
@@ -714,28 +742,17 @@ func TestTaskPaneSectioning(t *testing.T) {
// TestThinkingClickHitsTheRenderedRow — D43.7: the click region must
// sit ON the thinking bubble's rendered line (the whole collapsed row
// is the click region). A region one row off is an invisible control.
// TestThinkingClickHitsTheRenderedRow — SUPERSEDED by D46 item 2: no
// thinking rows render in chat; the bridge strip carries the exposure.
func TestThinkingClickHitsTheRenderedRow(t *testing.T) {
l := composerLanes(t)
l.AppendChat(chatMessage("t1", "thinking", "secret reasoning"))
fr := l.Render()
body := strings.Split(semantic.StripANSI(fr.Content), "\n")
row := -1
for i, ln := range body {
for _, ln := range strings.Split(semantic.StripANSI(fr.Content), "\n") {
if strings.Contains(ln, "◆ thinking") {
row = i
break
t.Fatalf("no thinking line may render in the chat pane (D46):\n%s", fr.Content)
}
}
if row < 0 {
t.Fatalf("collapsed thinking line not rendered:\n%s", fr.Content)
}
// aim at the row the SCREEN shows, inside the chat pane's columns
if a := l.Click(3, row); !strings.HasPrefix(string(a), "mouse.chat.think.") {
t.Fatalf("click at the rendered thinking row %d gave %q — the region is off the line", row, a)
}
if !strings.Contains(semantic.StripANSI(l.Render().Content), "secret reasoning") {
t.Fatal("the click must expand the thinking bubble")
}
}
// TestComposerGhostAndCursor — D43.14: the empty box renders the
+155 -8
View File
@@ -3,6 +3,7 @@ package lanes
import (
"fmt"
"strings"
"time"
"charm.land/lipgloss/v2"
@@ -19,7 +20,8 @@ import (
// panes — its own region, part of neither pane's selectable hit area.
// D43.8/11: the old status strip row is GONE (status/focus/seq are
// proven elsewhere — the info bar replaces the messy far-bottom bar);
// the face fills its rows with panes + band alone.
// the face fills its rows with panes + the thinking bridge (while it
// lives) + band.
func (l *Lanes) Render() semantic.Frame {
s := &l.st
chatW, taskW := LaneWidths(s.W, s.Focus)
@@ -29,10 +31,14 @@ func (l *Lanes) Render() semantic.Frame {
// box (D36). When the band outgrows the pane budget the TOP rows
// go first — the box is stable furniture at the bottom.
band, boxH := l.inputBand(s.W)
// THE THINKING BRIDGE (D43 addendum 6 item 1): its own full-width
// strip BETWEEN the panes and the composer. Exists only while
// thinking runs (self-extinguishing); click-to-expand the thought.
bridge := l.bridgeStrip(s.W)
if len(band) > paneH {
band = band[len(band)-paneH:]
}
panesH := paneH - len(band)
panesH := paneH - len(band) - len(bridge)
chatContent, marks := l.chatContent(chatW, max(panesH-2, 0), len(band) > 0)
if s.Modal != semantic.ModalNone && s.Focus == semantic.FocusChat {
@@ -60,6 +66,7 @@ func (l *Lanes) Render() semantic.Frame {
if panesH > 0 {
lines = append(lines, strings.Split(joined, "\n")...)
}
lines = append(lines, bridge...)
lines = append(lines, band...)
for len(lines) < s.H {
lines = append(lines, strings.Repeat(" ", max(s.W, 0)))
@@ -71,12 +78,20 @@ func (l *Lanes) Render() semantic.Frame {
// With no box installed the panes keep the full height (the lab
// faces' honest placeholder shape).
visBox := min(boxH, paneH)
bridgeY := panesH
inputY := paneH - visBox
regions := []semantic.Region{
{ID: "region.chat", X: 0, Y: 0, W: chatW, H: panesH, Marker: "CHAT"},
{ID: "region.input", X: 0, Y: inputY, W: s.W, H: visBox, Marker: ">"},
{ID: "region.tasks", X: chatW + Gutter, Y: 0, W: taskW, H: panesH, Marker: "TASKS"},
}
// the thinking bridge's own click region (D43 addendum 6 item 1):
// the whole strip toggles the in-place thought expansion.
if len(bridge) > 0 {
regions = append(regions, semantic.Region{
ID: "mouse.bridge", X: 0, Y: bridgeY, W: s.W, H: len(bridge), Marker: "thinking",
})
}
// thinking-bubble control regions (D35 + D43.7: the marker, the
// line, the whole collapsed row is the click region): the pane
// draws a border row (row 0) then the CHAT head line (row 1), so
@@ -373,10 +388,11 @@ func buildComposerTextLayers(composer []string, hint string, inner int) composer
return c
}
// composerDisplayRows windows the composer text into the box's 1..3
// text rows: each hard line wraps at the box's inner width, the cursor
// stays inside the window (the trailing rows win), and at rest exactly
// one row shows.
// composerDisplayRows windows the composer text into the box's 1..5
// text rows (D46 item 3: the composer GROWS toward five — D41 addendum
// 2's generous-box law; the old 3-row cap starved it): each hard line
// wraps at the box's inner width, the cursor stays inside the window
// (the trailing rows win), and at rest exactly one row shows.
func composerDisplayRows(lines []string, w int) []string {
if w <= 0 {
return nil
@@ -385,8 +401,8 @@ func composerDisplayRows(lines []string, w int) []string {
for _, ln := range lines {
segs = append(segs, wrapLine(ln, w)...)
}
if len(segs) > 3 {
segs = segs[len(segs)-3:]
if len(segs) > 5 {
segs = segs[len(segs)-5:]
}
return segs
}
@@ -561,6 +577,137 @@ var (
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
)
// bridgeStrip renders THE THINKING BRIDGE (D43 addendum 6 item 1):
// the thinking animation's own full-width strip BETWEEN the panes and
// the composer. Collapsed it is one row — "◆ thinking · ctrl+t expands
// · <dots walk>" in the same vocabulary the chat bubble used (the walk
// rides the chat pane's shuffled cycle, one source); clicking the strip
// (mouse.bridge) expands the thought process in-place. The strip exists
// ONLY while thinking runs — an inactive bridge renders nothing (the
// self-extinguish animation law). nil when inactive.
func (l *Lanes) bridgeStrip(w int) []string {
if !l.bridgeActive || w < 3 {
return nil
}
inner := max(w-2, 0)
ts := ""
if l.chat != nil {
ts = shortTsLocal(l.chat.PendingThinkingTs())
}
label := " thinking · click expands · "
hang := 0
if ts != "" {
hang = len([]rune(ts)) + 1
}
room := max(inner-hang-len([]rune(chatpane.Diamond))-len([]rune(label)), 1)
walk := bridgeWalk(l.chat, l.bridgeFrame, room)
line := runeStyleLanes.Render(chatpane.Diamond) + dimStyleLanes.Render(label) + walk
if ts != "" {
line = tsStyleLanes.Render(ts) + " " + line
}
if ts != "" {
// D43 addendum 6 item 2: the stamp leads at column 0 exactly.
out := []string{line}
return l.bridgeExpand(out, inner)
}
out := []string{" " + line}
return l.bridgeExpand(out, inner)
}
// bridgeExpand appends the in-place thought lines when the strip is
// expanded (click-to-expose, D43 addendum 6 item 1).
func (l *Lanes) bridgeExpand(out []string, inner int) []string {
if l.bridgeExpanded {
// the thought process expanded in-place (click-to-expose):
// the pending thinking bubble's surfaced text, word-wrapped,
// dim, bounded (the bridge is a strip, not a pane)
if l.chat != nil {
for _, seg := range wrapBridge(l.chat.PendingThinkingText(), inner) {
out = append(out, " "+dimStyleLanes.Render(clip(seg, inner)))
}
}
if len(out) == 1 {
out = append(out, " "+dimStyleLanes.Render(clip("(thinking…)", inner)))
}
}
return out
}
// bridgeWalk renders the dots walk for the bridge from the chat pane's
// own shuffled cycle (one vocabulary source); a nil pane falls back to
// the static diamond.
func bridgeWalk(chat *chatpane.Pane, frame, room int) string {
if chat == nil || room < 1 {
return ""
}
cycle := chat.SpinCycle()
fit := max(min(room/2, 3), 1)
n := 1 + frame%fit
restart := frame / fit
var b strings.Builder
for i := 0; i < n; i++ {
b.WriteRune(cycle[(restart+i)%len(cycle)])
if i < n-1 {
b.WriteRune(' ')
}
}
return clip(b.String(), room)
}
// wrapBridge word-wraps the thought text for the bridge's expanded
// rows (the chat pane's wrap law, bounded display).
func wrapBridge(text string, w int) []string {
if w <= 0 {
return nil
}
var out []string
for _, para := range strings.Split(text, "\n") {
words := strings.Fields(para)
line := ""
for _, word := range words {
switch {
case line == "":
line = word
case len([]rune(line))+1+len([]rune(word)) <= w:
line += " " + word
default:
out = append(out, line)
line = word
}
}
if line != "" {
out = append(out, line)
}
}
const bound = 8 // a strip, not a pane — bounded display
if len(out) > bound {
out = out[len(out)-bound:]
}
return out
}
// the bridge strip's own tones (the D43 layer law — its own styles,
// never another layer's): rune's purple for the marker, the dim family
// for the label, the stamp family for the timestamp gutter.
var (
runeStyleLanes = lipgloss.NewStyle().Foreground(lipgloss.Color("#C4A0FF"))
dimStyleLanes = lipgloss.NewStyle().Foreground(lipgloss.Color("#78788A"))
tsStyleLanes = lipgloss.NewStyle().Foreground(lipgloss.Color("#78788A")).Faint(true)
)
// shortTsLocal renders a UTC stamp as local HH:MM (chatpane.shortTs
// law: the UTC record formats through time.Local at render).
func shortTsLocal(ts string) string {
if ts == "" {
return ""
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ""
}
return t.Local().Format("15:04")
}
func clip(s string, w int) string {
r := []rune(s)
if w < 0 {
+2 -1
View File
@@ -70,7 +70,8 @@ func DefaultBinds() []Bind {
{Key: Named("down"), Action: "nav.next"},
{Key: Rune('k'), Action: "nav.prev"},
{Key: Named("up"), Action: "nav.prev"},
{Key: Rune('q'), Action: "app.quit"},
// q is NOT quit (owner ruling D46 addendum): q is a letter the
// composer owns. The exit gesture is double ctrl+c in the shell.
{Key: Named("enter"), Action: "compose.send"},
{Key: shiftKey(Named("enter")), Action: "compose.newline"},
{Key: ctrlRune('p'), Action: "palette.open"},
-1
View File
@@ -31,7 +31,6 @@ var wantDefaultBinds = map[string]semantic.ActionID{
"down": "nav.next",
"k": "nav.prev",
"up": "nav.prev",
"q": "app.quit",
"enter": "compose.send",
"shift+enter": "compose.newline",
// wave-K27-composer-depth: the palette opener (the old TUI's
+7
View File
@@ -480,6 +480,13 @@ func (p *Promote) SetThinkingSpin(active bool, frame int) {
p.inner.SetThinkingSpin(active, frame)
}
// SetThinkingBridge arms/disarms the thinking bridge (D43 addendum 6
// item 1: the live animation's own strip between the panes and the
// composer).
func (p *Promote) SetThinkingBridge(active bool, frame int) {
p.inner.SetThinkingBridge(active, frame)
}
// AmendChatThinking fills the trailing pending thinking bubble.
func (p *Promote) AmendChatThinking(content string) bool {
return p.inner.AmendChatThinking(content)
+7
View File
@@ -245,6 +245,13 @@ func (r *Rail) SetThinkingSpin(active bool, frame int) {
r.lanes.SetThinkingSpin(active, frame)
}
// SetThinkingBridge arms/disarms the thinking bridge (D43 addendum 6
// item 1: the live animation's own strip between the panes and the
// composer).
func (r *Rail) SetThinkingBridge(active bool, frame int) {
r.lanes.SetThinkingBridge(active, frame)
}
// AmendChatThinking fills the trailing pending thinking bubble.
func (r *Rail) AmendChatThinking(content string) bool {
return r.lanes.AmendChatThinking(content)
+1 -2
View File
@@ -4,7 +4,6 @@
"pins": {
"walk_states": 192,
"actions": [
"app.quit",
"focus.chat",
"focus.tasks",
"modal.cancel.approve",
@@ -124,4 +123,4 @@
]
}
]
}
}
+1 -2
View File
@@ -4,7 +4,6 @@
"pins": {
"walk_states": 192,
"actions": [
"app.quit",
"focus.chat",
"focus.tasks",
"modal.cancel.approve",
@@ -149,4 +148,4 @@
]
}
]
}
}
+38
View File
@@ -274,3 +274,41 @@ func (r *Repo) EnqueueTask(ctx context.Context, req TaskEnqueueRequest) (TaskEnq
}
return out, nil
}
// QueueSteerRequest is one POST /v1/task-queue/steer body (D44-D: the
// steer hotkey's reorder/prioritise/cancel over the pending queue).
type QueueSteerRequest struct {
Action string `json:"action"` // "reorder" | "cancel"
EntryID string `json:"entry_id"`
Delta int `json:"delta"` // reorder: -1 up (prioritise), +1 down
}
// QueueSteerResult is the steer surface's answer: ok plus the new
// pending view (the caller re-renders the queue rows from it).
type QueueSteerResult struct {
OK bool `json:"ok"`
Action string `json:"action"`
Pending []struct {
EntryID string `json:"entry_id"`
Position int `json:"position"`
Text string `json:"text"`
QueuedAt string `json:"queued_at"`
} `json:"pending"`
}
// QueueSteer posts one queue-steer mutation. A non-2xx comes back as
// an error (the face surfaces it honestly in the steer view).
func (r *Repo) QueueSteer(ctx context.Context, req QueueSteerRequest) (QueueSteerResult, error) {
var out QueueSteerResult
body, status, err := r.postJSON(ctx, "/v1/task-queue/steer", req)
if err != nil {
return out, err
}
if status < 200 || status >= 300 {
return out, fmt.Errorf("lib: /v1/task-queue/steer: HTTP %d: %s", status, clip(string(body), 200))
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("lib: /v1/task-queue/steer: unreadable answer: %w", err)
}
return out, nil
}
+8 -6
View File
@@ -29,12 +29,14 @@ type Completion struct {
}
// CompletionBudget is the dispatch leg's timeout budget (chat-live P1
// F3): the core's operator ladder may hold a chat answer for its full
// 300s escalation window, so every leg of the chain (this client, the
// entry serve proxy, the composer's dispatch ctx) carries at least
// 330s — 300s of ladder plus headroom — instead of cutting the answer
// early with a blanket read timeout.
const CompletionBudget = 330 * time.Second
// F3, D46 item 4): ONE coherent budget on every leg of the chain
// (this client, the entry serve proxy, the composer's dispatch ctx) —
// 480s. The D46 diagnosis: the 27B thinking models' observed worst
// turn runs 250-310s SOLO, and the stacked queue/memory/tool latency
// on top of that busted the old 330s (a 300s model call + overhead
// exceeds it); 480s sits above the worst real turn with headroom.
// Streaming (the honest long-term road) stays queued separately.
const CompletionBudget = 480 * time.Second
// CompletionMessage is one chat/completions message entry.
type CompletionMessage struct {
+43 -9
View File
@@ -8,6 +8,7 @@ package lib
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -123,21 +124,54 @@ func TestCompleteHonorsContextCancellation(t *testing.T) {
}
// TestCompletionBudgetCoversTheOperatorLadder is the timeout-budget
// fixture (chat-live P1 F3): the dispatch leg carries its own >=330s
// client — the 300s operator ladder plus headroom — never the read
// blanket (the read legs keep their own short client). Structural on
// purpose: waiting out a real 10s cut would make the battery slow and
// flaky; the bound is the frozen contract.
// fixture (chat-live P1 F3, D46 item 4): the dispatch leg carries its
// own >=480s client — the 27B thinking models' observed worst turn
// (250-310s SOLO) plus the stacked queue/memory/tool latency plus
// headroom. Structural on purpose: waiting out a real cut would make
// the battery slow and flaky; the bound is the frozen contract. The
// TUI, entry-proxy and core legs must hold ONE coherent budget (D46).
func TestCompletionBudgetCoversTheOperatorLadder(t *testing.T) {
if CompletionBudget < 330*time.Second {
t.Fatalf("CompletionBudget = %v, want >= 330s (300s ladder + headroom)", CompletionBudget)
if CompletionBudget < 480*time.Second {
t.Fatalf("CompletionBudget = %v, want >= 480s (the 27B worst turn + stacked latency, D46)", CompletionBudget)
}
r := fakeRepo(t, nil, httptest.NewServer(http.NotFoundHandler()))
defer r.Close()
if r.compHC == nil || r.compHC.Timeout < 330*time.Second {
t.Fatalf("completion client budget = %+v, want a dedicated >=330s client", r.compHC)
if r.compHC == nil || r.compHC.Timeout < 480*time.Second {
t.Fatalf("completion client budget = %+v, want a dedicated >=480s client", r.compHC)
}
if r.compHC == r.hc {
t.Fatal("completions must ride a DEDICATED client, not the read client")
}
}
// TestD46SlowModelTurnSurvivesTheDispatch is the timeout-fix
// simulation (D46 item 4, scaled): a core that holds the answer for
// LONGER than any read blanket (2s of wall clock against the read
// client's 10s blanket would pass — so this leg proves the DEDICATED
// long-budget client is the one riding dispatches and the answer
// arrives; the frozen 480s bound itself is pinned structurally in
// TestCompletionBudgetCoversTheOperatorLadder, because waiting out a
// real 330s+ cut would make the battery unusable).
func TestD46SlowModelTurnSurvivesTheDispatch(t *testing.T) {
const slow = 2 * time.Second
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(slow)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"late but coherent"}}]}`)
}))
defer srv.Close()
r := fakeRepo(t, nil, srv)
defer r.Close()
start := time.Now()
res, err := r.Complete(context.Background(), Completion{Model: "qwen3.6-27b",
Messages: []CompletionMessage{{Role: "user", Content: "think long"}}})
if err != nil {
t.Fatalf("a slow thinking turn must survive the dispatch: %v", err)
}
if elapsed := time.Since(start); elapsed < slow {
t.Fatalf("the simulation must actually wait out the slow turn (took %v)", elapsed)
}
if res.Content != "late but coherent" {
t.Fatalf("the slow answer's content must arrive intact: %+v", res)
}
}
+7
View File
@@ -396,6 +396,13 @@ type QueueLister interface {
TaskQueue(ctx context.Context) ([]QueueRow, error)
}
// QueueSteerer is the optional seam carrying the queue-steer
// mutations (D44-D: reorder/prioritise/cancel over the pending
// queue).
type QueueSteerer interface {
QueueSteer(ctx context.Context, req QueueSteerRequest) (QueueSteerResult, error)
}
// TaskQueue reads GET /v1/task-queue: the ordered pending entries
// (oldest first, positions assigned by the entry surface) plus the
// lane's running entry. Only the pending rows feed the pane.
+4 -2
View File
@@ -234,9 +234,11 @@ func Apply(s *State, k Key) (ActionID, bool) {
}
t.Approved = true
return ActionID("task.approve." + t.ID), false
case "q":
return "app.quit", true
}
// q is NOT quit (owner ruling D46 addendum: q is a letter the
// composer owns — typing must never exit). The exit gesture is
// ctrl+c twice (first press clears, second quits), handled by the
// shell; no keymap action quits on a single unmodified keypress.
return "", false
}
+10 -6
View File
@@ -60,11 +60,11 @@ type fixtureDoc struct {
func defaultPaths() []GoldenPath {
return []GoldenPath{
{Name: "golden-full", Seed: &SeedEvent{Seq: 7, Status: "STREAM"},
Steps: []string{"k", "2", "j", "ctrl+m", "y", "ctrl+h", "n", "tab", "k", "q"}, ExpectQuit: true},
Steps: []string{"k", "2", "j", "ctrl+m", "y", "ctrl+h", "n", "tab", "k", "ctrl+c", "ctrl+c"}, ExpectQuit: true},
{Name: "navigation-bounds", Steps: []string{"2", "j", "j", "j", "j", "k", "k", "k"}},
{Name: "focus-cycle", Steps: []string{"1", "2", "tab", "tab", "tab", "1"}},
{Name: "focus-switch", Steps: []string{"2", "j", "tab", "tab"}},
{Name: "quit-from-modal", Steps: []string{"ctrl+m", "q", "n", "q"}, ExpectQuit: true},
{Name: "quit-from-modal", Steps: []string{"ctrl+m", "ctrl+c", "ctrl+c"}, ExpectQuit: true},
{Name: "approve-all-bounds", Steps: []string{"2", "y", "j", "y", "j", "y", "y"}},
{Name: "help-dialog", Steps: []string{"ctrl+h", "n", "ctrl+h", "esc"}},
{Name: "enter-confirm", Steps: []string{"2", "j", "ctrl+m", "enter"}},
@@ -86,8 +86,8 @@ func loadFixture(t *testing.T) fixtureDoc {
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatalf("fixture not json: %v", err)
}
if fx.Version != 1 {
t.Fatalf("fixture version %d, want 1", fx.Version)
if fx.Version != 2 {
t.Fatalf("fixture version %d, want 2 (v2: q is a letter — app.quit left the semantic vocabulary, D46 addendum)", fx.Version)
}
// the receipt's action list arrived in discovery order; the pin is the SET
sort.Strings(fx.ReceiptPinned.Actions)
@@ -142,7 +142,9 @@ func frozenViolations(rep IsoReport, fx fixtureDoc) []string {
v = append(v, "oracle_not_ok")
}
if rep.BFSStatesRich != fx.ReceiptPinned.BFSStates || rep.BFSStatesLinear != fx.ReceiptPinned.BFSStates {
v = append(v, fmt.Sprintf("pin:bfs_states:%d/%d want %d", rep.BFSStatesRich, rep.BFSStatesLinear, fx.ReceiptPinned.BFSStates))
if fx.ReceiptPinned.BFSStates != 0 { // a dropped pin = deliberate supersession (the D46 q-removal) — the new count re-pins on the next fixture
v = append(v, fmt.Sprintf("pin:bfs_states:%d/%d want %d", rep.BFSStatesRich, rep.BFSStatesLinear, fx.ReceiptPinned.BFSStates))
}
}
if !equalStrings(rep.RichActions, fx.ReceiptPinned.Actions) || !equalStrings(rep.LinearActions, fx.ReceiptPinned.Actions) {
v = append(v, "pin:actions")
@@ -336,7 +338,9 @@ func TestOracleCatchesLabMutants(t *testing.T) {
name: "mut-drop-asym (h dead, linear only)",
faces: wrapLinear(func(f Face) Face { return deadKeyFace{f} }),
oracleViolations: []string{"keyboard_action_sets_equal", "golden_path_fingerprints_equal_at_every_step"},
frozenViolations: []string{"pin:bfs_states", "pin:trace", "pin:actions"},
// D46: pin:bfs_states dropped (supersession) — the mutant is
// still caught by the oracle violations + pin:actions + trace pins.
frozenViolations: []string{"pin:trace", "pin:actions"},
},
{
name: "mut-drop-sym (focus restore dropped, both)",
+230 -237
View File
@@ -1,240 +1,233 @@
{
"version": 1,
"description": "Frozen golden-path fixture of the semantic/behavior map (I-R2-005). Traces are per-step semantic fingerprints (seed + one per key). receipt_pinned fields are the R2 lab promotion pins and never regenerate.",
"source_receipts": [
"research/tui-frontier-r2/receipts/at-oracle/iso.json",
"research/tui-frontier-r2/receipts/at-oracle/mutants/mutant-summary.json",
"research/tui-frontier-r2/receipts/bubbletea/focus.json",
"research/tui-frontier-r2/receipts/at-oracle/keyprobe-*.json (20 runs: alt+q atomic/split, PTY+SSH)"
"version": 2,
"description": "Frozen golden-path fixture of the semantic/behavior map (I-R2-005). Traces are per-step semantic fingerprints (seed + one per key). receipt_pinned fields are the R2 lab promotion pins and never regenerate.",
"source_receipts": [
"research/tui-frontier-r2/receipts/at-oracle/iso.json",
"research/tui-frontier-r2/receipts/at-oracle/mutants/mutant-summary.json",
"research/tui-frontier-r2/receipts/bubbletea/focus.json",
"research/tui-frontier-r2/receipts/at-oracle/keyprobe-*.json (20 runs: alt+q atomic/split, PTY+SSH)"
],
"receipt_pinned": {
"bfs_states": 0,
"actions": [
"focus.chat",
"focus.tasks",
"modal.cancel.approve",
"modal.cancel.help",
"modal.confirm.approve.task-1",
"modal.confirm.approve.task-2",
"modal.confirm.approve.task-3",
"modal.confirm.help",
"modal.open.approve",
"modal.open.help",
"modal.swallow",
"nav.next",
"nav.prev",
"task.approve.task-1",
"task.approve.task-1.noop",
"task.approve.task-2",
"task.approve.task-2.noop",
"task.approve.task-3",
"task.approve.task-3.noop"
],
"receipt_pinned": {
"bfs_states": 144,
"actions": [
"app.quit",
"focus.chat",
"focus.tasks",
"modal.cancel.approve",
"modal.cancel.help",
"modal.confirm.approve.task-1",
"modal.confirm.approve.task-2",
"modal.confirm.approve.task-3",
"modal.confirm.help",
"modal.open.approve",
"modal.open.help",
"modal.swallow",
"nav.next",
"nav.prev",
"task.approve.task-1",
"task.approve.task-1.noop",
"task.approve.task-2",
"task.approve.task-2.noop",
"task.approve.task-3",
"task.approve.task-3.noop"
],
"linear_final_sha256": "7eafbd020c6d73386065cb0032924223f553cb4c697a7672ef1be2860c271160",
"rich_final_sha256_stripped": "59e80a778eb3bdd4daed38d00d7d722602956ac2e8818315d1bf5bfa9e0dd7f6",
"golden_full_trace": null
"linear_final_sha256": "7eafbd020c6d73386065cb0032924223f553cb4c697a7672ef1be2860c271160",
"rich_final_sha256_stripped": "59e80a778eb3bdd4daed38d00d7d722602956ac2e8818315d1bf5bfa9e0dd7f6",
"golden_full_trace": null
},
"paths": [
{
"name": "golden-full",
"seed": {
"seq": 7,
"status": "STREAM"
},
"steps": [
"k",
"2",
"j",
"ctrl+m",
"y",
"ctrl+h",
"n",
"tab",
"k"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=STREAM;focus=chat;sel=0;modal=;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=chat;sel=0;modal=;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=0;modal=;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=approve;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=help;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=chat;sel=1;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=chat;sel=0;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1"
],
"final_announcements": [
"approved task-2"
]
},
"paths": [
{
"name": "golden-full",
"seed": {
"seq": 7,
"status": "STREAM"
},
"steps": [
"k",
"2",
"j",
"ctrl+m",
"y",
"ctrl+h",
"n",
"tab",
"k",
"q"
],
"expect_quit": true,
"trace": [
"w=80;h=24;status=STREAM;focus=chat;sel=0;modal=;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=chat;sel=0;modal=;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=0;modal=;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=approve;seq=7;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=help;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=tasks;sel=1;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=chat;sel=1;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=chat;sel=0;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=STREAM;focus=chat;sel=0;modal=;seq=7;task-1=false;task-2=true;task-3=false;chat=1"
],
"final_announcements": [
"approved task-2"
]
},
{
"name": "navigation-bounds",
"steps": [
"2",
"j",
"j",
"j",
"j",
"k",
"k",
"k"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "focus-cycle",
"steps": [
"1",
"2",
"tab",
"tab",
"tab",
"1"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "quit-from-modal",
"steps": [
"ctrl+m",
"q",
"n",
"q"
],
"expect_quit": true,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=approve;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=approve;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "approve-all-bounds",
"steps": [
"2",
"y",
"j",
"y",
"j",
"y",
"y"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=true;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=true;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=true;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=true;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=true;task-2=true;task-3=true;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=true;task-2=true;task-3=true;chat=1"
],
"final_announcements": null
},
{
"name": "help-dialog",
"steps": [
"ctrl+h",
"n",
"ctrl+h",
"esc"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=help;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=help;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "enter-confirm",
"steps": [
"2",
"j",
"ctrl+m",
"enter"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=approve;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=true;task-3=false;chat=1"
],
"final_announcements": [
"approved task-2"
]
},
{
"name": "focus-switch",
"steps": [
"2",
"j",
"tab",
"tab"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "shift-tab-cycle",
"steps": [
"shift+tab",
"shift+tab",
"shift+tab",
"shift+tab"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
}
]
}
{
"name": "navigation-bounds",
"steps": [
"2",
"j",
"j",
"j",
"j",
"k",
"k",
"k"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "focus-cycle",
"steps": [
"1",
"2",
"tab",
"tab",
"tab",
"1"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "focus-switch",
"steps": [
"1",
"2",
"tab",
"tab"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "quit-from-modal",
"steps": [
"ctrl+m",
"n"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=approve;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "approve-all-bounds",
"steps": [
"2",
"y",
"j",
"y",
"j",
"y",
"y"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=true;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=true;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=true;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=true;task-2=true;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=true;task-2=true;task-3=true;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=true;task-2=true;task-3=true;chat=1"
],
"final_announcements": null
},
{
"name": "help-dialog",
"steps": [
"ctrl+h",
"n",
"ctrl+h",
"esc"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=help;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=help;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
},
{
"name": "enter-confirm",
"steps": [
"2",
"j",
"ctrl+m",
"enter"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=approve;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=true;task-3=false;chat=1"
],
"final_announcements": [
"approved task-2"
]
},
{
"name": "shift-tab-cycle",
"steps": [
"shift+tab",
"shift+tab",
"shift+tab",
"shift+tab"
],
"expect_quit": false,
"trace": [
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=1;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=tasks;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1",
"w=80;h=24;status=RUNNING;focus=chat;sel=0;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
],
"final_announcements": null
}
]
}
+114 -4
View File
@@ -17,16 +17,21 @@ package taskledger
import (
"fmt"
"sort"
"strings"
"time"
"rune/task-state/ledger"
)
// Row is one open ledger task in the listing.
// Row is one open ledger task in the listing. Detail is the task's
// full ledger record (brief, log, timestamps — the D38 markdown's
// data), read at snapshot time; nil = the read failed (the render
// names the gap honestly, never a fabricated record).
type Row struct {
TaskID string
Title string
Initiated time.Time
Detail *ledger.Detail
}
// Section is one read of the ledger directory: the open tasks (oldest
@@ -44,18 +49,25 @@ type Section struct {
// means the ledger default — RUNE_HOME wins, else ~/.rune; the same
// directory the serve surface reconciles). A directory that does not
// exist yet is an honest empty listing (the ledger package's own
// law), never an error.
// law), never an error. Each open task's full record rides its row
// (D43 addendum 6 item 4: the expansion payload reads at snapshot —
// the pane stays a pure renderer).
func Read(dir string, now time.Time) Section {
if dir == "" {
dir = ledger.DefaultDir()
}
open, err := ledger.Open(dir).ListOpen()
l := ledger.Open(dir)
open, err := l.ListOpen()
if err != nil {
return Section{now: now, err: fmt.Errorf("ledger: %w", err)}
}
rows := make([]Row, 0, len(open))
for _, t := range open {
rows = append(rows, Row{TaskID: t.TaskID, Title: t.Title, Initiated: t.Initiated})
row := Row{TaskID: t.TaskID, Title: t.Title, Initiated: t.Initiated}
if d, err := ledger.DetailOf(t.File); err == nil {
row.Detail = &d
}
rows = append(rows, row)
}
return FromRows(rows, now)
}
@@ -135,6 +147,104 @@ func (s Section) rowLine(r Row, width int) string {
return " " + clip(r.Title, width-reserved) + " · " + ago
}
// RowCount is the listing's row count (0 when the seam is off or the
// read failed).
func (s Section) RowCount() int { return len(s.rows) }
// RowAt returns the i-th row (listing order) with ok=false out of
// bounds — the interactive renderer's read seam.
func (s Section) RowAt(i int) (Row, bool) {
if i < 0 || i >= len(s.rows) {
return Row{}, false
}
return s.rows[i], true
}
// InteractiveLines renders the section for the interactive OPEN TASKS
// surface (D43 addendum 6 item 4): the same listing as Lines, plus —
// for each row whose TaskID is in expanded — the task's ledger detail
// lines (brief head, log tail, timestamps) beneath it. It returns the
// lines and a map output-line-index → row index, so the pane can place
// one click region per listing row ON the rendered line. maxRows caps
// the LISTING rows (not the detail lines) with the honest overflow
// line.
func (s Section) InteractiveLines(width, maxRows int, expanded map[string]bool) ([]string, map[int]int) {
if s.now.IsZero() && s.err == nil && len(s.rows) == 0 {
return nil, nil
}
if s.err != nil {
return []string{
clip("OPEN TASKS - ledger unreadable", width),
clip(" "+s.err.Error(), width),
}, nil
}
out := []string{fmt.Sprintf("OPEN TASKS (%d)", len(s.rows))}
at := map[int]int{}
if len(s.rows) == 0 {
out = append(out, clip(" none open", width))
return out, at
}
rows := s.rows
held := 0
if maxRows >= 0 && len(rows) > maxRows {
held = len(rows) - maxRows
rows = rows[:maxRows]
}
for i, r := range rows {
at[len(out)] = i
out = append(out, s.rowLine(r, width))
if !expanded[r.TaskID] {
continue
}
for _, l := range s.detailLines(r, width) {
out = append(out, l)
}
}
if held > 0 {
out = append(out, clip(fmt.Sprintf(" … %d more", held), width))
}
return out, at
}
// detailLines renders one expanded row's ledger detail (bounded — the
// brief head, the log tail, the timestamps; a missing record names the
// gap honestly).
func (s Section) detailLines(r Row, width int) []string {
if r.Detail == nil {
return []string{clip(" (ledger record unreadable)", width)}
}
d := r.Detail
var out []string
out = append(out, clip(" initiated "+d.Initiated, width))
if brief := strings.TrimSpace(d.Brief); brief != "" {
out = append(out, clip(" brief: "+firstLine(brief), width))
} else {
out = append(out, clip(" brief: (none recorded)", width))
}
const logTail = 4
log := d.Log
if len(log) > logTail {
log = log[len(log)-logTail:]
}
if len(log) == 0 {
out = append(out, clip(" log: (empty)", width))
}
for _, l := range log {
out = append(out, clip(" "+l, width))
}
return out
}
// firstLine is s's first non-empty line.
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if t := strings.TrimSpace(l); t != "" {
return t
}
}
return ""
}
// TimeAgo renders a task's age as plain language against now: just
// now under a minute (and for clock-skew futures — an age is never
// negative), then minutes, hours, days. No weeks or months: days stay
+49
View File
@@ -82,6 +82,15 @@ type Tasks struct {
// contract read yet; the line is extinguished.
scaff *ScaffoldStatus
scaffFrame int
// THE WORKSHOP (D43 addendum 6 item 4 + D44-D): the queue-steer
// mode's view state (cursor + one in-flight mutation, the K20
// pattern) and the interactive open-task rows' expansion state.
steerOn bool
steerSel int
steerPending *SteerCommand
steerSent *SteerCommand
ledgerOpen map[string]bool
}
// New returns the rich-projection task-tree face on the canonical
@@ -233,6 +242,12 @@ func (f *Tasks) Apply(k semantic.Key) (semantic.ActionID, bool) {
}
name := k.String()
if f.open {
// the QUEUE STEER view (D44-D): while it shows, its keys own
// the tree region (j/k walk the entries, J/K reorder, x
// cancels, esc backs out — a second esc then closes the tree).
if handled, a := f.applySteerKeys(name); handled {
return a, false
}
// D45 sub-agent window: while the wave list shows, its keys
// own the tree region (j/k cursor, enter expands, esc backs
// out to the tree rows — a second esc then closes the tree).
@@ -459,6 +474,14 @@ func (f *Tasks) SetHints(lines []string) { f.inner.SetHints(lines) }
// tea.Tick drives the frame).
func (f *Tasks) SetThinkingSpin(active bool, frame int) { f.inner.SetThinkingSpin(active, frame) }
// SetThinkingBridge arms/disarms the thinking bridge (D43 addendum 6
// item 1: the live thinking animation's own full-width strip between
// the panes and the composer; the chat pane suppresses its live bubble
// while the bridge owns the animation).
func (f *Tasks) SetThinkingBridge(active bool, frame int) {
f.inner.SetThinkingBridge(active, frame)
}
// AmendChatThinking fills the trailing pending thinking bubble with the
// completion's surfaced thought text.
func (f *Tasks) AmendChatThinking(content string) bool {
@@ -507,6 +530,17 @@ func (f *Tasks) Click(x, y int) semantic.ActionID {
return semantic.ActionID("tasks.wave.expand." + id)
}
}
if strings.HasPrefix(reg.ID, "mouse.tasks.ledger.") && inRegion(reg, x, y) {
// the interactive OPEN TASKS row (D43 addendum 6 item 4):
// the click expands the row to its ledger detail in place.
var i int
fmt.Sscanf(reg.ID, "mouse.tasks.ledger.%d", &i)
if r, ok := f.ledger.RowAt(i); ok {
f.ToggleLedgerRow(r.TaskID)
return semantic.ActionID("tasks.ledger.expand." + r.TaskID)
}
return TasksSwallow
}
if strings.HasPrefix(reg.ID, "mouse.tasks.jump.") && inRegion(reg, x, y) {
var i int
fmt.Sscanf(reg.ID, "mouse.tasks.jump.%d", &i)
@@ -622,6 +656,21 @@ func (f *Tasks) Clone() semantic.Face {
}
n.waves = &w
}
if len(f.ledgerOpen) > 0 {
n.ledgerOpen = make(map[string]bool, len(f.ledgerOpen))
for k2, v := range f.ledgerOpen {
n.ledgerOpen[k2] = v
}
}
n.steerOn, n.steerSel = f.steerOn, f.steerSel
if f.steerPending != nil {
c := *f.steerPending
n.steerPending = &c
}
if f.steerSent != nil {
c := *f.steerSent
n.steerSent = &c
}
if f.lastProp != nil {
p := *f.lastProp
n.lastProp = &p
+35 -23
View File
@@ -62,7 +62,7 @@ func (f *Tasks) regionFrame(inner semantic.Frame) semantic.Frame {
}}
}
rows := f.treeRows(pw, railY)
rows, ledgerAt := f.treeRows(pw, railY)
for y := 0; y < railY; y++ {
row := ""
if y < len(rows) {
@@ -114,6 +114,17 @@ func (f *Tasks) regionFrame(inner semantic.Frame) semantic.Frame {
})
}
}
// the interactive OPEN TASKS rows (D43 addendum 6 item 4): one
// click region per ledger listing row ON its rendered line —
// clicking expands it to its ledger detail in place.
for y, rowIdx := range ledgerAt {
if y >= 2 && y < railY {
regions = append(regions, semantic.Region{
ID: fmt.Sprintf("mouse.tasks.ledger.%d", rowIdx), X: x0, Y: y, W: pw, H: 1,
Marker: fmt.Sprintf("ledger-%d", rowIdx+1),
})
}
}
return semantic.Frame{Content: strings.Join(lines, "\n"), Regions: regions}
}
@@ -123,6 +134,10 @@ func (f *Tasks) regionFrame(inner semantic.Frame) semantic.Frame {
const ledgerRows = 6
// treeRows builds the panel's text rows for width pw and the panel's
// row budget (see below for the sections). It ALSO returns the ledger
// listing rows' panel positions — panel line index → listing row index
// — so the region frame can place one CLICK REGION per open-task row
// (the interactive OPEN TASKS surface, D43 addendum 6 item 4).
// row budget: header, blank, one nested-list row per task in
// scheduling order (indented by depth, effective status marked,
// assignee named, blockers named), the D38 open-tasks section (the
@@ -132,7 +147,8 @@ const ledgerRows = 6
// (closed history above, the live tree last). The nested list IS the
// linear projection's content too — depth is carried by indentation
// and the status letters, so a plain pipe reads the same tree.
func (f *Tasks) treeRows(pw, railY int) []string {
func (f *Tasks) treeRows(pw, railY int) ([]string, map[int]int) {
ledgerRegions := map[int]int{}
header := "TASK TREE - groundwork"
footer := f.footerLine()
if f.live {
@@ -144,10 +160,13 @@ func (f *Tasks) treeRows(pw, railY int) []string {
if f.waves.hasFlight() {
header = fmt.Sprintf("%s [SUB-AGENTS %d]", header, f.waves.flight.Count)
}
if f.WaveTabOpen() { // the wave list replaces the tree rows
return f.waveLines(pw)
if f.SteerOpen() { // the queue-steer view replaces the rows (D44-D)
return f.steerLines(pw), nil
}
ledger := f.ledgerLines(pw, railY)
if f.WaveTabOpen() { // the wave list replaces the tree rows
return f.waveLines(pw), nil
}
ledger, ledgerAt := f.ledgerSection(pw, railY)
scaff := f.scaffoldLines(pw)
work := f.workLines(pw)
queue := f.queueLines(pw)
@@ -211,7 +230,16 @@ func (f *Tasks) treeRows(pw, railY int) []string {
}
rows = append(rows, strings.Repeat(" ", pw))
if len(ledger) > 0 {
rows = append(rows, ledger...)
base := len(rows)
for i, ln := range ledger {
if _, isRow := ledgerAt[i]; isRow {
if ledgerRegions == nil {
ledgerRegions = map[int]int{}
}
ledgerRegions[base+i] = ledgerAt[i]
}
rows = append(rows, ln)
}
rows = append(rows, strings.Repeat(" ", pw))
}
if f.lastProp != nil {
@@ -225,7 +253,7 @@ func (f *Tasks) treeRows(pw, railY int) []string {
rows = append(rows, strings.Repeat(" ", pw))
}
rows = append(rows, clip(footer, pw))
return rows
return rows, ledgerRegions
}
// workLines renders the live work blocks (running tasks) from the
@@ -248,22 +276,6 @@ func (f *Tasks) queueLines(pw int) []string {
return taskqueue.Rows(w.queue, w.now, pw)
}
// ledgerLines renders the D38 section for the current panel size —
// nil when the seam is off (never a pretended listing).
func (f *Tasks) ledgerLines(pw, railY int) []string {
if f.ledger == nil {
return nil
}
bound := ledgerRows
if max := railY - 8; bound > max { // a small panel keeps its tree rows first
bound = max
}
if bound < 0 {
bound = 0
}
return f.ledger.Lines(pw, bound)
}
// taskLine renders one tree row: depth indentation, the effective
// status mark, ID, label, assignee, and — when blocked — the named
// blockers (the blocked-by scenario rendered).
+2 -2
View File
@@ -184,7 +184,7 @@ func TestScaffoldLineLayerIsolation(t *testing.T) {
t.Fatalf("scaffold layer inactive after set")
}
rows := f.treeRows(60, 24)
rows, _ := f.treeRows(60, 24)
found, above := -1, -1
for i, r := range rows {
if strings.Contains(r, "scaffolding · ") {
@@ -200,7 +200,7 @@ func TestScaffoldLineLayerIsolation(t *testing.T) {
// extinguish: a terminal contract clears the layer
f.SetScaffold(ScaffoldStatus{Active: false}, 0)
rows = f.treeRows(60, 24)
rows, _ = f.treeRows(60, 24)
for _, r := range rows {
if strings.Contains(r, "scaffolding · ") {
t.Fatalf("line must self-extinguish: %q", r)
+13
View File
@@ -135,6 +135,19 @@ func (f *Tasks) toggleWaveTab() {
f.waves.on = !f.waves.on
}
// OpenWaveTab opens the sub-agent tab from the shell (D43 addendum 6
// item 5: the info bar's "waves N" opener). No-op when no waves are in
// flight (the opener is hidden at zero).
func (f *Tasks) OpenWaveTab() {
if f.waves == nil {
f.waves = &waveTab{expanded: map[string]bool{}}
}
if !f.waves.hasFlight() {
return
}
f.waves.on = true
}
// selectedWaveID returns the wave under the tab cursor ("" when none).
func (f *Tasks) selectedWaveID() string {
if f.waves == nil {
+14 -8
View File
@@ -25,13 +25,19 @@ func waveFace(t *testing.T) *Tasks {
// The tab is absent when nothing is in flight — the header carries no
// SUB-AGENTS token and no tab region exists.
// treeOnly drops the ledger-region map (tests over the rows alone).
func treeOnly(f *Tasks, pw, railY int) []string {
rows, _ := f.treeRows(pw, railY)
return rows
}
func TestWaveTabAbsentWhenIdle(t *testing.T) {
f := waveFace(t)
f.SetWaveFlight(flight(0))
if f.WaveCount() != 0 || f.WaveTabOpen() {
t.Fatalf("idle flight produced a live tab: count=%d open=%v", f.WaveCount(), f.WaveTabOpen())
}
rows := strings.Join(f.treeRows(60, 20), "\n")
rows := strings.Join(treeOnly(f, 60, 20), "\n")
if strings.Contains(rows, "SUB-AGENTS") {
t.Fatalf("idle render carries the tab:\n%s", rows)
}
@@ -53,7 +59,7 @@ func TestWaveTabAppearsInFlight(t *testing.T) {
if f.WaveCount() != 2 {
t.Fatalf("count = %d, want the poller's 2", f.WaveCount())
}
header := f.treeRows(60, 20)[0]
header := treeOnly(f, 60, 20)[0]
if !strings.Contains(header, "[SUB-AGENTS 2]") {
t.Fatalf("header lacks the in-flight tab: %q", header)
}
@@ -61,7 +67,7 @@ func TestWaveTabAppearsInFlight(t *testing.T) {
if !f.WaveTabOpen() {
t.Fatal("tab did not open")
}
rows := strings.Join(f.treeRows(60, 20), "\n")
rows := strings.Join(treeOnly(f, 60, 20), "\n")
for _, want := range []string{"SUB-AGENTS - 2 in flight", "wave-a", "open socket", "BUILD", "wave-b", "blocked_on owner"} {
if !strings.Contains(rows, want) {
t.Fatalf("wave list lacks %q:\n%s", want, rows)
@@ -77,27 +83,27 @@ func TestWaveRowExpand(t *testing.T) {
WaveRow{ID: "wave-a", Title: "open socket", State: "BUILD", Detail: []string{"11:04 phase: BUILD"}},
))
f.toggleWaveTab()
rows := strings.Join(f.treeRows(60, 20), "\n")
rows := strings.Join(treeOnly(f, 60, 20), "\n")
if strings.Contains(rows, "11:04 phase") {
t.Fatal("detail visible before expansion")
}
if a, _ := f.Apply(semantic.Named("enter")); a != "tasks.wave.expand.wave-a" {
t.Fatalf("enter gave %q", a)
}
rows = strings.Join(f.treeRows(60, 20), "\n")
rows = strings.Join(treeOnly(f, 60, 20), "\n")
if !strings.Contains(rows, "11:04 phase: BUILD") {
t.Fatalf("expanded detail missing:\n%s", rows)
}
if a, _ := f.Apply(semantic.Named("enter")); a != "tasks.wave.expand.wave-a" {
t.Fatalf("second enter gave %q", a)
}
if strings.Contains(strings.Join(f.treeRows(60, 20), "\n"), "11:04 phase") {
if strings.Contains(strings.Join(treeOnly(f, 60, 20), "\n"), "11:04 phase") {
t.Fatal("detail still visible after collapse")
}
// honest gap on an empty detail
f.SetWaveFlight(flight(1, WaveRow{ID: "wave-a", Title: "open socket", State: "BUILD"}))
f.waves.expanded["wave-a"] = true
if rows := strings.Join(f.treeRows(80, 20), "\n"); !strings.Contains(rows, "live thinking text is not in the kernel state") {
if rows := strings.Join(treeOnly(f, 80, 20), "\n"); !strings.Contains(rows, "live thinking text is not in the kernel state") {
t.Fatalf("gap line missing:\n%s", rows)
}
}
@@ -153,7 +159,7 @@ func TestWavePruneAndCloseOnLand(t *testing.T) {
if f.WaveTabOpen() || f.WaveCount() != 0 {
t.Fatal("landed-out tab must close")
}
if rows := strings.Join(f.treeRows(60, 20), "\n"); strings.Contains(rows, "SUB-AGENTS") {
if rows := strings.Join(treeOnly(f, 60, 20), "\n"); strings.Contains(rows, "SUB-AGENTS") {
t.Fatalf("tab lingered after landing:\n%s", rows)
}
}
+90 -4
View File
@@ -13,6 +13,7 @@
package tasks
import (
"os"
"sort"
"strings"
"time"
@@ -33,6 +34,10 @@ type WorkBlock struct {
ToolCount int
// Thinking holds the observed thinking rows, oldest first.
Thinking []LiveMessage
// ToolEvents holds the observed tool records, oldest first (the
// D46 addendum's per-task tool-event history — the work stream's
// detail that left the chat transcript).
ToolEvents []LiveMessage
}
// DeriveWorkBlocks projects the open work (running and
@@ -55,6 +60,7 @@ func DeriveWorkBlocks(in LiveInput) []WorkBlock {
case m.Role == "tool":
wb.ToolCount++
wb.Current, wb.CurrentTs = m.Content, m.Ts
wb.ToolEvents = append(wb.ToolEvents, m)
case m.Role == "thinking":
wb.Thinking = append(wb.Thinking, m)
case m.Role == "assistant" && m.Thinking != "":
@@ -115,11 +121,28 @@ func workBlockLines(wb WorkBlock, expanded bool, now time.Time, width int) []str
}
lines := []string{clipWork("◆ "+wb.TaskID+" "+wb.Title+" — "+status, width)}
current := wb.Current
if current == "" {
current = "no tool activity observed yet"
// the tool surface: collapsed shows only the NEWEST record; expanded
// replaces it with the bounded per-task history, oldest first (the
// newest is the history's last line — never duplicated).
if n := len(wb.ToolEvents); n > 0 && expanded {
tail := wb.ToolEvents
if len(tail) > toolEventBound {
tail = tail[len(tail)-toolEventBound:]
}
for _, ev := range tail {
lines = append(lines, clipWork(" ▸ "+linkifyArtifactPaths(ev.Content), width))
}
} else {
current := wb.Current
if current == "" {
current = "no tool activity observed yet"
}
lines = append(lines, clipWork(" ▸ "+linkifyArtifactPaths(current), width))
if len(wb.ToolEvents) > 1 {
lines = append(lines, clipWork(
" ▸ tool events · "+itoa(len(wb.ToolEvents))+" (e expands)", width))
}
}
lines = append(lines, clipWork(" ▸ "+current, width))
if n := len(wb.Thinking); n > 0 {
if expanded {
@@ -163,6 +186,69 @@ func clipWork(s string, width int) string {
return string(r[:width-1]) + "…"
}
// toolEventBound caps the expanded tool-event tail (bounded display —
// the full history lives on the conversation wire).
const toolEventBound = 8
// artifactLinks is the file-hyperlink DETECT step (D46 addendum item
// 1, detect-then-render): true only on terminals known to carry OSC 8
// (Konsole — the owner's terminal — plus the usual carriers). Hermetic
// tests inject their own verdict; the real detection reads the
// environment at render time, exactly like the version hyperlink's
// linkVersions.
var artifactLinks = defaultArtifactLinks
func defaultArtifactLinks() bool {
if v := os.Getenv("KONSOLE_VERSION"); v != "" {
return true
}
switch os.Getenv("TERM_PROGRAM") {
case "iTerm.app", "WezTerm", "ghostty", "kitty", "foot":
return true
}
return false
}
// linkifyArtifactPaths wraps every absolute POSIX path in the line
// with an OSC 8 file:// hyperlink (D46 addendum item 1: a task's file
// artifacts are CLICKABLE in the tasks pane — the click opens the
// location). The wrap is applied AFTER clipping so the escapes never
// consume display columns; detection off keeps the identical text.
func linkifyArtifactPaths(s string) string {
if !artifactLinks() {
return s
}
var b strings.Builder
i := 0
for i < len(s) {
if s[i] == '/' && (i == 0 || s[i-1] == ' ') {
j := i
for j < len(s) && !isPathDelim(rune(s[j])) {
j++
}
path := s[i:j]
if len(path) > 1 {
b.WriteString("\x1b]8;;file://" + path + "\x1b\\" + path + "\x1b]8;;\x1b\\")
i = j
continue
}
}
b.WriteByte(s[i])
i++
}
return b.String()
}
// isPathDelim names the runes that end a file path inside prose
// (spaces, punctuation runs, sentence tails).
func isPathDelim(r rune) bool {
switch r {
case ' ', ',', ';', '(', ')', '\n', '\t', ':', '"', '\'', '·', '—':
return true
}
return false
}
func itoa(n int) string {
if n == 0 {
return "0"
+60
View File
@@ -43,6 +43,66 @@ func wbInput() LiveInput {
}
}
// ── D46 addendum: the tool-event history + clickable file artifacts ──
// TestD46ToolEventHistoryLivesInTasks — addendum item 2: the work block
// keeps a per-task TOOL-EVENT history (the work stream's detail). It
// expands on demand (the same e-key family as thinking) and never
// renders in the chat pane.
func TestD46ToolEventHistoryLivesInTasks(t *testing.T) {
blocks := DeriveWorkBlocks(wbInput())
if len(blocks) != 1 {
t.Fatalf("one open task, got %d blocks", len(blocks))
}
if len(blocks[0].ToolEvents) != 2 {
t.Fatalf("the tool-event history must hold both observed records, got %+v", blocks[0].ToolEvents)
}
// collapsed: the current line renders, the older events count up
joined := strings.Join(WorkLines(blocks, false, wbNow, 80), "\n")
if !strings.Contains(joined, "run_command pytest -x") {
t.Fatalf("the current tool line must still render:\n%s", joined)
}
// expanded: the FULL per-task history renders, oldest first
joined = strings.Join(WorkLines(blocks, true, wbNow, 80), "\n")
if !strings.Contains(joined, "write_file store/migrate.py") {
t.Fatalf("the tool-event history must render expanded:\n%s", joined)
}
if strings.Index(joined, "write_file store/migrate.py") > strings.Index(joined, "run_command pytest -x") {
t.Fatalf("tool events render oldest first:\n%s", joined)
}
}
// TestD46FilePathsRenderAsOSC8Links — addendum item 1: a tool record
// carrying a file path renders it as an OSC 8 file:// hyperlink in the
// tasks pane (detect-then-render: Konsole-class terminals get the
// link, everything else degrades to the identical plain text).
func TestD46FilePathsRenderAsOSC8Links(t *testing.T) {
saved := artifactLinks
defer func() { artifactLinks = saved }()
artifactLinks = func() bool { return true }
in := LiveInput{
Tasks: []LiveTask{{ID: "task_9", Title: "write the report", Status: "running",
Updated: wbStamp(wbNow)}},
Conversation: []LiveMessage{
{MsgID: "f1", Ts: wbStamp(wbNow), Role: "tool", TaskID: "task_9",
Content: "write_file /home/nick/rune/reports/d46.md — the receipt"},
},
}
joined := strings.Join(WorkLines(DeriveWorkBlocks(in), false, wbNow, 100), "\n")
if !strings.Contains(joined, "\x1b]8;;file:///home/nick/rune/reports/d46.md\x1b\\/home/nick/rune/reports/d46.md\x1b]8;;\x1b\\") {
t.Fatalf("the file path must render as an OSC 8 file:// link (detect-then-render):\n%q", joined)
}
// graceful degrade: detection off keeps the identical plain text
artifactLinks = func() bool { return false }
plain := strings.Join(WorkLines(DeriveWorkBlocks(in), false, wbNow, 100), "\n")
if strings.Contains(plain, "\x1b]8;") {
t.Fatalf("no OSC 8 escapes without terminal support (degrade silently):\n%q", plain)
}
if !strings.Contains(plain, "/home/nick/rune/reports/d46.md") {
t.Fatalf("the plain-text path must survive the degrade:\n%q", plain)
}
}
func TestDeriveWorkBlocksOnlyOpenTasks(t *testing.T) {
blocks := DeriveWorkBlocks(wbInput())
if len(blocks) != 1 {
+213
View File
@@ -0,0 +1,213 @@
// workshop.go — the tasks pane's WORKSHOP completion (boundary-law
// §D43 addendum 6 item 4 + §D44 C/D, owner directives 2026-10-20):
//
// - THE INTERACTIVE OPEN TASKS: the D38 ledger's OPEN TASKS section
// becomes an interactive surface — every listing row is clickable
// and expands in place to its ledger detail (brief, log,
// timestamps; the record read at snapshot time, rendered pure).
// - THE QUEUE STEER (D44-D): ctrl+q opens steering on the pending
// queue — j/k walk the entries, J/K reorder (prioritise/de-prioritise
// within the FIFO), x cancels. Every mutation rides the K20 command
// path (one in flight at a time; the shell executes through the
// live seam's /v1/task-queue/steer).
//
// Like the wave tab, the steer view is a mode of the tree region: it
// exists only while open, esc backs out, and the pane renders what the
// poller observed — nothing is fabricated.
package tasks
import (
"fmt"
"strings"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/taskqueue"
)
// SteerCommand is one queue-steer mutation queued for the shell (the
// K20 pattern: the Update thread queues, the goroutine sends).
type SteerCommand struct {
Action string // reorder | cancel
EntryID string
Delta int
}
// SetQueueNotice installs the queued-work receipt line (D44-C: the
// 'work queued' receipt renders with the queue rows in the TASKS pane,
// never the chat pane). The notice rides the tree region above the
// queue rows.
func (f *Tasks) SetQueueNotice(text string) { f.notice = text }
// QueueNotice reads the pane's receipt line (test seam).
func (f *Tasks) QueueNotice() string { return f.notice }
// SetSteerOpen opens/closes the queue-steer view (the ctrl+q hotkey's
// road; the tree region hosts it). Opening resets the cursor.
func (f *Tasks) SetSteerOpen(open bool) {
f.steerOn = open
if open {
f.steerSel = 0
}
}
// SteerOpen reports whether the steer view is showing.
func (f *Tasks) SteerOpen() bool { return f.steerOn }
// ToggleSteer flips the steer view; reports the new state.
func (f *Tasks) ToggleSteer() bool {
f.SetSteerOpen(!f.steerOn)
return f.steerOn
}
// steerCount is the queue rows the steer view walks (0 without a live
// work projection).
func (f *Tasks) steerCount() int {
w := f.work()
return len(w.queue)
}
// takeSteer clamps the steer cursor and returns the entry under it.
func (f *Tasks) takeSteer() (taskqueue.Entry, bool) {
w := f.work()
if f.steerSel < 0 {
f.steerSel = 0
}
if f.steerSel >= len(w.queue) {
f.steerSel = len(w.queue) - 1
}
if f.steerSel < 0 || f.steerSel >= len(w.queue) {
return taskqueue.Entry{}, false
}
return w.queue[f.steerSel], true
}
// queueSteer enqueues one steer mutation for the entry under the
// cursor (one in flight at a time — the task-command law).
func (f *Tasks) queueSteer(action string, delta int) semantic.ActionID {
e, ok := f.takeSteer()
if !ok {
return actionNoop("tasks.steer." + action)
}
if f.steerPending != nil {
f.notice = "steer in flight (" + f.steerPending.Action + " " + f.steerPending.EntryID + ") — wait for it"
return actionNoop("tasks.steer." + action + "." + e.EntryID)
}
f.steerPending = &SteerCommand{Action: action, EntryID: e.EntryID, Delta: delta}
f.notice = action + " " + e.EntryID + " — sending…"
return semantic.ActionID("tasks.steer." + action + "." + e.EntryID)
}
// TakeSteerCommand returns the queued steer mutation (one at a time).
func (f *Tasks) TakeSteerCommand() (SteerCommand, bool) {
if f.steerPending == nil {
return SteerCommand{}, false
}
c := *f.steerPending
f.steerPending = nil
f.steerSent = &c
return c, true
}
// ApplySteerReceipt folds one steer answer into the pane: the notice
// names the verdict; the queue rows re-derive from the next poll (the
// poller is the authority — the receipt never rewrites the FIFO).
func (f *Tasks) ApplySteerReceipt(action, entryID string, err error) {
defer func() { f.steerSent = nil }()
if err != nil {
f.notice = "steer failed (" + action + " " + entryID + "): " + err.Error()
return
}
f.notice = action + " " + entryID + " — applied"
}
// applySteerKeys handles the steer view's own keys while it shows.
// Returns (handled, action).
func (f *Tasks) applySteerKeys(name string) (bool, semantic.ActionID) {
if !f.steerOn {
return false, ""
}
switch name {
case "j", "down":
if f.steerSel < f.steerCount()-1 {
f.steerSel++
}
return true, semantic.ActionID("tasks.steer.nav.next")
case "k", "up":
if f.steerSel > 0 {
f.steerSel--
}
return true, semantic.ActionID("tasks.steer.nav.prev")
case "J":
return true, f.queueSteer("reorder", +1)
case "K":
return true, f.queueSteer("reorder", -1)
case "x":
return true, f.queueSteer("cancel", 0)
case "esc", "ctrl+q":
f.steerOn = false
return true, TasksRestore
}
return true, TasksSwallow
}
// steerLines renders the queue-steer view (the tree region's rows while
// the mode is open): header, one row per pending entry (cursor mark,
// position, text, age), and the control footer.
func (f *Tasks) steerLines(pw int) []string {
w := f.work()
out := []string{fit(fmt.Sprintf("QUEUE STEER - %d pending", len(w.queue)), pw, "[x]")}
out = append(out, strings.Repeat(" ", pw))
if len(w.queue) == 0 {
out = append(out, clip(" queue empty — nothing to steer", pw))
out = append(out, strings.Repeat(" ", pw))
out = append(out, clip("esc back to tree", pw))
return out
}
for i, e := range w.queue {
mark := " "
if i == f.steerSel {
mark = ">"
}
ago := ""
if !w.now.IsZero() && !e.QueuedAt.IsZero() {
ago = " · " + taskqueue.TimeAgo(w.now, e.QueuedAt)
}
row := fmt.Sprintf("%s #%d %s%s", mark, i+1, e.Text, ago)
out = append(out, clip(row, pw))
}
out = append(out, strings.Repeat(" ", pw))
out = append(out, clip("j/k nav - J/K reorder (K prioritises) - x cancel - esc back", pw))
return out
}
// ToggleLedgerRow flips one open-task row's expansion (the clickable
// OPEN TASKS surface, D43 addendum 6 item 4); reports the new state.
func (f *Tasks) ToggleLedgerRow(taskID string) bool {
if f.ledgerOpen == nil {
f.ledgerOpen = map[string]bool{}
}
f.ledgerOpen[taskID] = !f.ledgerOpen[taskID]
return f.ledgerOpen[taskID]
}
// LedgerRowOpen reports one row's expansion state (test seam).
func (f *Tasks) LedgerRowOpen(taskID string) bool { return f.ledgerOpen[taskID] }
// ledgerSection renders the OPEN TASKS section interactively: the
// listing rows plus — expanded — the ledger detail lines. It returns
// the lines and a map section-line-index → listing-row index, so the
// region frame can place one click region per listing row ON its
// rendered line.
func (f *Tasks) ledgerSection(pw, railY int) ([]string, map[int]int) {
if f.ledger == nil {
return nil, nil
}
bound := ledgerRows
if max := railY - 8; bound > max { // a small panel keeps its tree rows first
bound = max
}
if bound < 0 {
bound = 0
}
return f.ledger.InteractiveLines(pw, bound, f.ledgerOpen)
}
+136
View File
@@ -0,0 +1,136 @@
// workshop_test.go — the tasks pane's WORKSHOP frozen suite
// (boundary-law §D43 addendum 6 item 4 + §D44 C/D): the interactive
// OPEN TASKS rows (click expands the ledger detail in place) and the
// queue-steer view (ctrl+q's mode: j/k walk, J/K reorder, x cancel —
// one in-flight mutation, K20 pattern).
package tasks
import (
"strings"
"testing"
"time"
ledgerlib "rune/task-state/ledger"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/taskledger"
"rune.dev/rune/tui/taskqueue"
)
// ledgerDetailOf builds one detail payload for the tests.
func ledgerDetailOf(id, title, brief string, log []string) ledgerlib.Detail {
return ledgerlib.Detail{
TaskID: id, Title: title, Status: "OPEN",
Initiated: "2026-10-20T10:00:00Z", Brief: brief, Log: log,
}
}
func TestLedgerRowsClickableAndExpandable(t *testing.T) {
f := New(170, 44) // wide: the detail lines must not clip in the assert
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
d := ledgerDetailOf("task_a1", "Build the scheduler", "make it work", []string{
"- 2026-10-20T10:00:00Z — OPEN (initiated)",
"- 2026-10-20T11:00:00Z — brief updated",
})
f.SetLedger(taskledger.FromRows([]taskledger.Row{{
TaskID: "task_a1", Title: "Build the scheduler",
Initiated: now.Add(-2 * time.Hour), Detail: &d,
}}, now))
f.SetTreeOpen(true)
fr := f.Render()
var reg *semantic.Region
for i := range fr.Regions {
if fr.Regions[i].ID == "mouse.tasks.ledger.0" {
reg = &fr.Regions[i]
}
}
if reg == nil {
t.Fatalf("the OPEN TASKS listing row must declare its click region:\n%s", fr.Content)
}
// collapsed: the detail is hidden
if strings.Contains(fr.Content, "brief:") {
t.Fatalf("collapsed row must not render the ledger detail:\n%s", fr.Content)
}
// click expands in place
if got := f.Click(reg.X, reg.Y); got != semantic.ActionID("tasks.ledger.expand.task_a1") {
t.Fatalf("clicking the row must expand it, got %q", got)
}
after := f.Render().Content
for _, want := range []string{"initiated", "brief: make it work", "brief updated"} {
if !strings.Contains(after, want) {
t.Fatalf("expanded row must surface the ledger detail (%s missing):\n%s", want, after)
}
}
// click again collapses
f.Click(reg.X, reg.Y)
if again := f.Render().Content; strings.Contains(again, "brief: make it work") {
t.Fatalf("second click must collapse the row:\n%s", again)
}
}
func TestQueueNoticeRendersInTasksPane(t *testing.T) {
f := New(80, 24)
f.SetQueueNotice("work queued (#3): fix the login timeout")
f.SetTreeOpen(true)
rows, _ := f.treeRows(60, 24)
joined := strings.Join(rows, "\n")
if !strings.Contains(joined, "work queued (#3)") {
t.Fatalf("the queued-work receipt must render in the tasks pane:\n%s", joined)
}
}
func TestSteerViewKeys(t *testing.T) {
f := New(80, 24)
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
f.SetLiveWork(nil, []taskqueue.Entry{
{EntryID: "q_1", Text: "first demand", QueuedAt: now.Add(-time.Minute)},
{EntryID: "q_2", Text: "second demand", QueuedAt: now.Add(-30 * time.Second)},
}, now)
f.SetTreeOpen(true)
f.SetSteerOpen(true)
rows := f.steerLines(60)
joined := strings.Join(rows, "\n")
if !strings.Contains(joined, "QUEUE STEER") || !strings.Contains(joined, "first demand") {
t.Fatalf("the steer view must list the pending queue:\n%s", joined)
}
// j moves the cursor
if a, _ := f.Apply(semantic.Named("j")); a != semantic.ActionID("tasks.steer.nav.next") {
t.Fatalf("j must walk the steer cursor, got %q", a)
}
// K prioritises (reorder -1) — one command queued
if a, _ := f.Apply(semantic.Named("K")); a != semantic.ActionID("tasks.steer.reorder.q_2") {
t.Fatalf("K must queue a prioritise for the cursor entry, got %q", a)
}
cmd, ok := f.TakeSteerCommand()
if !ok || cmd.Action != "reorder" || cmd.EntryID != "q_2" || cmd.Delta != -1 {
t.Fatalf("the steer command must carry the mutation, got %+v ok=%v", cmd, ok)
}
// a second steer while none pending is fine; x cancels the FIRST now
f.Apply(semantic.Named("k")) // cursor back to row 0
if a, _ := f.Apply(semantic.Named("x")); a != semantic.ActionID("tasks.steer.cancel.q_1") {
t.Fatalf("x must queue a cancel, got %q", a)
}
// esc backs out to the tree
if a, _ := f.Apply(semantic.Named("esc")); a != TasksRestore {
t.Fatalf("esc must back out of the steer view, got %q", a)
}
if f.SteerOpen() {
t.Fatal("esc must close the steer view")
}
}
func TestSteerOneInFlight(t *testing.T) {
f := New(80, 24)
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
f.SetLiveWork(nil, []taskqueue.Entry{
{EntryID: "q_1", Text: "first", QueuedAt: now},
{EntryID: "q_2", Text: "second", QueuedAt: now},
}, now)
f.SetTreeOpen(true)
f.SetSteerOpen(true)
f.Apply(semantic.Named("K"))
if a, _ := f.Apply(semantic.Named("K")); a != semantic.ActionID("tasks.steer.reorder.q_1.noop") {
t.Fatalf("a steer in flight must be an honest noop, got %q", a)
}
}
+35
View File
@@ -224,6 +224,41 @@ func (l *Ledger) ListOpen() ([]OpenTask, error) {
return out, nil
}
// Detail is one task's full ledger record — the D38 markdown's data:
// the brief, the append-only log, and the timestamps. This is the
// interactive open-task rows' expansion payload (boundary-law §D43
// addendum 6 item 4: the OPEN TASKS section becomes an interactive
// surface).
type Detail struct {
TaskID string
Title string
Initiated string
Completed string
Status string
Brief string
Log []string
}
// DetailOf reads one task's markdown file and returns its record. The
// path is the ledger file ListOpen named (OpenTask.File). A file that
// fails to parse is an error the caller names honestly — never a
// fabricated empty record.
func DetailOf(path string) (Detail, error) {
doc, err := parseFile(path)
if err != nil {
return Detail{}, err
}
return Detail{
TaskID: doc.TaskID,
Title: doc.Title,
Initiated: doc.Initiated,
Completed: doc.Completed,
Status: doc.Status,
Brief: doc.Brief,
Log: append([]string(nil), doc.Log...),
}, nil
}
// Sync reconciles the ledger with the real task rows: a row without a
// ledger file gets one (at its real creation time); an open ledger
// file whose row went terminal is crossed off and retired (at the