Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09b19d6cf1 | ||
|
|
7934f38044 | ||
|
|
c5f8e1a0d7 | ||
|
|
71a027dcca | ||
|
|
796416b036 | ||
|
|
696d55f1eb | ||
|
|
fd8dd9bfce | ||
|
|
5398f9b522 | ||
|
|
a8a05f3c40 | ||
|
|
ffe40e5af6 | ||
|
|
d824b8f43c | ||
|
|
c77f4e7728 | ||
|
|
32fc394e0d | ||
|
|
8b3665f468 | ||
|
|
3ecbe066e8 | ||
|
|
7c90e950e3 | ||
|
|
8faf66a76a | ||
|
|
598e5c48ad | ||
|
|
97726e07c3 | ||
|
|
ac2bd1d091 | ||
|
|
ab5cf12728 | ||
|
|
36a4c7b25f | ||
|
|
874a6ef324 | ||
|
|
06ef478ca9 | ||
|
|
a5026031cb | ||
|
|
bb59aa0d92 | ||
|
|
296fc3d52a | ||
|
|
240d6e9f73 | ||
|
|
3baa74872d | ||
|
|
156f791d84 | ||
|
|
9bfecf43fb | ||
|
|
09f6a511ff | ||
|
|
9f7dccf3d5 | ||
|
|
94c72b35a1 | ||
|
|
bce65f3f17 | ||
|
|
2d8403144b | ||
|
|
a0cb876755 | ||
|
|
79656acf50 | ||
|
|
a1fbefe4f1 | ||
|
|
dcb5536797 | ||
|
|
45f1eb3c73 | ||
|
|
db0270e675 | ||
|
|
b7f25370a3 | ||
|
|
06055565f0 | ||
|
|
2f05865ddc | ||
|
|
1d73589b58 | ||
|
|
79e63ee7f6 | ||
|
|
073fcd305f | ||
|
|
36d8921d11 | ||
|
|
c40204795f |
No files matched your search
@@ -167,6 +167,30 @@ jobs:
|
||||
\ init \\\n --version \"$RELEASE_VERSION\" \\\n --server \"$RELEASE_SERVER_URL\"\
|
||||
\ --repo \"$RELEASE_REPO\" \\\n --store store/scaffold/current.json \\\n\
|
||||
\ ${EXIT_NOTE:+--exit-note \"$EXIT_NOTE\"}"
|
||||
- name: Plain-English narrative at the top of the release body
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
# OWNER DIRECTIVE (plain-English releases): prepend a
|
||||
# "# What's new — plain English" section to the release body,
|
||||
# derived from the conventional-commit range since the previous
|
||||
# tag. Go-side (src/go/release-notes narrative.go) — zero new
|
||||
# Python. Idempotent: a body already carrying the section is left
|
||||
# alone. Additive and non-blocking: a missing toolchain or a failed
|
||||
# prepend must never fail a release.
|
||||
LAST_TAG=$(python3 scripts/tag_repair.py --latest-tag)
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
echo "::warning::go toolchain unavailable — release published without the plain-English narrative"
|
||||
exit 0
|
||||
fi
|
||||
(cd src/go/release-notes && go build -o /tmp/rune-release-notes .) || {
|
||||
echo "::warning::release-notes build failed — narrative skipped"; exit 0; }
|
||||
/tmp/rune-release-notes prepend-narrative \
|
||||
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
|
||||
--tag "v${RELEASE_VERSION}" --since "$LAST_TAG" || {
|
||||
echo "::warning::narrative prepend failed — release body left unchanged"; exit 0; }
|
||||
- name: Push tui-v<version> tag via git so the tag-push event fires tui-release (D27)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.321.0**
|
||||
**Current release: v0.331.0**
|
||||
|
||||
**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.**
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { existsSync, appendFileSync } from "node:fs"
|
||||
// Injection policy: only append when the capsule is non-empty. A plain
|
||||
// "hello" gets nothing (§5: if nothing relevant exists, inject nothing).
|
||||
|
||||
const BRIDGE = "/mnt/workdrive/issue161-harness/src/rune/builder_bridge_cli.py"
|
||||
const BRIDGE = "/mnt/workdrive/rune/src/rune/builder_bridge_cli.py"
|
||||
const MARKER_PREFIX = "/tmp/rune-bridge-"
|
||||
|
||||
// Interpreter resolution (T22 repair): the previous hardcoded
|
||||
@@ -35,7 +35,8 @@ const MARKER_PREFIX = "/tmp/rune-bridge-"
|
||||
// all of them. If nothing exists the hook degrades as before.
|
||||
const PY_CANDIDATES = [
|
||||
process.env.MIMO_BRIDGE_PY,
|
||||
"/mnt/workdrive/issue161-harness/.test-venv/bin/python",
|
||||
"/mnt/workdrive/rune/.test-venv/bin/python",
|
||||
"/mnt/workdrive/rune/.venv/bin/python",
|
||||
"/home/nick/.venvs/memory/bin/python3",
|
||||
"/usr/bin/python3",
|
||||
].filter((p) => typeof p === "string" && p.length > 0)
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,43 @@
|
||||
# CI E2E Rig (`scripts/ci_e2e_rig.sh`)
|
||||
|
||||
The permanent, reusable CI end-to-end proof rig, promoted from the
|
||||
release deep-dive's tag-repair proof rig (contract
|
||||
`ADMIT-wave-ci-e2e-rig`).
|
||||
|
||||
## What it does
|
||||
|
||||
1. Registers a **scratch** Gitea runner (labels: `rig-scratch`).
|
||||
2. Creates a **scratch** repo (`rig-scratch-<timestamp>`).
|
||||
3. Pushes a minimal trigger commit + the workflow under test.
|
||||
4. Runs the workflow end-to-end on the scratch runner.
|
||||
5. Verifies outputs (run state + expected artifacts).
|
||||
6. Tears everything down — **teardown-safe**: repo delete, runner
|
||||
unregister, and scratch-dir removal all run from an `EXIT` trap, so
|
||||
a failed proof run never leaks resources.
|
||||
|
||||
## Modes
|
||||
|
||||
- `scripts/ci_e2e_rig.sh --dry-run` — prints the plan, launches nothing
|
||||
(this is what the contract tests exercise; no live Gitea required).
|
||||
- `scripts/ci_e2e_rig.sh --workflow tui-release.yml [--keep]` — live
|
||||
proof run. Requires `RUNE_GITEA_URL` + `RUNE_GITEA_TOKEN`; fails
|
||||
loudly when either is missing.
|
||||
|
||||
## Safety properties
|
||||
|
||||
- Scratch-only: the production repo and production runners are never
|
||||
touched; the rig creates its own namespace-bound resources.
|
||||
- Teardown on failure: the `EXIT` trap fires on any non-zero path.
|
||||
- `--keep` retains the scratch dir for post-mortem (still tears down
|
||||
the runner and repo).
|
||||
|
||||
## Guarded regressions
|
||||
|
||||
`tests/test_workflow_script_contract.py` extended to cover the three
|
||||
pipelines (`auto-version.yml`, `tui-release.yml`, `maintenance.yml`)
|
||||
so any future edit reintroducing the **SIGPIPE pipe**
|
||||
(`git tag --sort=-v:refname | head -1` under pipefail — exit 141, the
|
||||
runs-29929/30435/30636 class) or dropping the
|
||||
**GITEA_REF_NAME tag fallback** (GITEA_REF_NAME → GITHUB_REF_NAME →
|
||||
`git describe --tags --exact-match`, empty on the rune-ci-desktop
|
||||
runner) goes RED at test time, not in production.
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Spec: the activity pane + the answer presentation system
|
||||
|
||||
Owner rulings recorded 2026-10-21 (this document IS the spec — the factory builds from it verbatim).
|
||||
One wave. Blocks until D47's chatpane work (general-7) lands; touches the same pane family.
|
||||
|
||||
## Part A — THE ACTIVITY PANE (the bridge strip reborn)
|
||||
|
||||
The bridge strip stops being a "thinking bar" and becomes the **activity pane**: a persistent,
|
||||
always-visible lane that shows WHAT the TUI is doing right now.
|
||||
|
||||
### A1. Activity states
|
||||
The pane dynamically labels the current activity. Minimum state set:
|
||||
|
||||
| activity | when |
|
||||
|---|---|
|
||||
| thinking | model reasoning turn in flight |
|
||||
| coding | tool loop running write/edit/run tools |
|
||||
| planning | PLAN-mode dispatch active |
|
||||
| researching | read-family tools running (grep/glob/read_file/list_directory) or webfeed |
|
||||
| idle | nothing in flight (pane rests; runic marks dim/absent per owner boot feedback) |
|
||||
|
||||
The state derives from REAL signals only — the mode-gated dispatch (tool names in the loop),
|
||||
the turn state machine. No guessed classifiers. A misfiring label is a defect (see #2529's
|
||||
she-coding false positive: same class).
|
||||
|
||||
### A2. Per-activity colour + animation
|
||||
Each activity has its OWN colour and its OWN animation pattern. The runic animations are KEPT.
|
||||
Runic marks may randomise colour per rune within the activity's palette (owner ruling: "could
|
||||
have random colours per rune"). The activity WORD animates while active.
|
||||
|
||||
### A3. The word itself
|
||||
The activity word randomises a rainbow colour while active (owner ruling #2527, subsumed here).
|
||||
|
||||
### A4. No timestamp on the activity pane (owner ruling #2527).
|
||||
|
||||
### A5. Always visible
|
||||
The pane never disappears; content changes with state (owner ruling #2527).
|
||||
|
||||
### A6. The + affordance (owner ruling #2528, upgraded)
|
||||
A clickable `+` in the activity pane opens a CENTERED WINDOW showing everything currently being
|
||||
processed (full thinking text, tool calls in flight, one block per full answer — not fragments).
|
||||
Keyboard parity per the tabs/windows canon. In-place click-to-expose on the strip is REMOVED.
|
||||
|
||||
### A7. Tasks-pane mirror
|
||||
The current activity also shows in the tasks pane work stream while active (owner ruling: "it
|
||||
should also show opened in the task pane").
|
||||
|
||||
### A8. Layer law
|
||||
The activity pane is an independent layer: own state machine, own tests, registered as a project
|
||||
citizen. The she-coding label (#2529) migrates INTO this state machine — its trigger diagnosed
|
||||
and either retired or made honest.
|
||||
|
||||
## Part B — THE ANSWER PRESENTATION SYSTEM (chatpane)
|
||||
|
||||
### B1. Proper markdown, zero model-side cost
|
||||
Parse the model's OWN markdown into blocks once per completed answer (TUI-side, after the turn).
|
||||
No prompt changes, no extra tokens, no context cost. Bounded dialect renderer (~6 block types),
|
||||
honest strip for exotics. No general markdown engine.
|
||||
|
||||
### B2. Block types and their presentation
|
||||
1. **Speech (prose paragraphs)** — bubble that GROWS with content; consecutive prose joins ONE
|
||||
bubble; left-aligned; bubble outline takes the SPEAKER'S colour (owner ruling #5 — colour
|
||||
identity stays, outlines made overt).
|
||||
2. **Table** — BREAKS OUT of the bubble: full-pane-width bordered table, header emphasised,
|
||||
border in the speaker's colour. Speech before/after joins bubbles again.
|
||||
3. **Bullet lists / numbered steps** — own presentation outside the bubble: left rail in the
|
||||
speaker's colour, hanging indent, steps numbered, genuine bullets dotted. NEVER per-sentence
|
||||
bullets (the D47 defect).
|
||||
4. **Code fences** — route to the tasks pane work stream (D47/D44 law, already contracted).
|
||||
5. **Headings** — full-width, speaker-coloured underline, no bubble.
|
||||
|
||||
### B3. Emphasis
|
||||
Respect the model's markdown emphasis now (bold/headers). Rune's OWN importance-flagging arrives
|
||||
later with her intelligence infrastructure — her voice, not a classifier (owner ruling #3).
|
||||
|
||||
### B4. Bubble breakage defect
|
||||
Long answers must never break the bubble (#2530a) — the growing-bubble model in B2 replaces the
|
||||
fixed-shape card. Answers left-aligned (#2530b).
|
||||
|
||||
## Verification shape (for the contract)
|
||||
- Red-first per block type: prose joins, table breakout, rail-list, speaker-coloured outlines,
|
||||
activity state transitions (real-signal driven), + window open/close, tasks-pane mirror.
|
||||
- PTY proof round: one landing, one deploy via build_paired.sh (owner contract).
|
||||
- Layer law: activity pane layer owns its state/tests; chatpane renderer owns its blocks/tests.
|
||||
- Zero new Python (owner directive: retire Python, don't add).
|
||||
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.321.0"
|
||||
version = "0.331.0"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Test-Vitality Audit & Conversion Report — wave-test-vitality-py (2026-10-20/21)
|
||||
|
||||
Contract: `ADMIT-wave-test-vitality-py` (phase=building), minted via
|
||||
`scripts/admit_wave_test_vitality_py.py`. Phase 2 of the vitality program:
|
||||
the src/rune Python suite (`tests/`, 3,702+ collected). Methodology and
|
||||
vitality classes inherited from the phase-1 audit
|
||||
(`reports/test-vitality-audit-2026-10-19.md`). ZERO deletions (owner's
|
||||
amended rule: conversion only; removal is a separate honest decision).
|
||||
|
||||
## Baseline
|
||||
|
||||
Suite green on the spot-checked packages before conversion
|
||||
(`tests/test_orchestration_outbox.py`, `tests/test_events_schema.py` —
|
||||
10 and 23 passed respectively).
|
||||
|
||||
## The audit (heuristic sweeps, documented)
|
||||
|
||||
Sweeps run over all of `tests/test_*.py`:
|
||||
|
||||
| Sweep | Hits | Classification |
|
||||
|---|---|---|
|
||||
| panic-only (`pytest.raises`/try-except with no pinned message, no post-assert) | ~150 sites / ~140 tests | the dominant ceremony class: the test proves "some exception fired", not "the right error fired" |
|
||||
| no-assert-at-all tests | same set (subset) | all were raise-only bodies; none silently succeeded |
|
||||
| fixed-value-on-random | 0 ceremony | random users are seeded (`random.Random(2479)` in test_outcome_utility_port, monkeypatched randint in test_night_mode) |
|
||||
| live-machine-state fixtures | 0 ceremony on spot-check (connection_pool had a historical temp-dir-substring flake, already fixed per its comment) |
|
||||
| synthetic keys | n/a — tui-only class, phase 1 |
|
||||
|
||||
Bounded conversion set chosen (representative of the panic-only class;
|
||||
the remaining ~130 raise-only sites are recorded as follow-up backlog,
|
||||
NOT silently blessed):
|
||||
|
||||
## Conversions (each red-first with a recorded mutation proof)
|
||||
|
||||
1. **`tests/test_orchestration_outbox.py`** — 5 tests converted from
|
||||
try/raise-AssertionError/except bodies (and bare `match="outbox corrupt"`)
|
||||
to field-specific pins: `state 123 is not a valid lifecycle state`,
|
||||
`ordering_key '2' is not an int`, `expected_remote_version '0' is not
|
||||
an int`, `KeyError: ghost`. 10/10 green.
|
||||
*Mutation proof*: disabled the `ordering_key` type check in
|
||||
`src/rune/orchestration/outbox.py` →
|
||||
`test_load_wrong_typed_ordering_key_raises_corrupt` went RED; the old
|
||||
un-pinned form stayed green under the same mutation (a generic
|
||||
KeyError/parse error satisfied it). Reverted; suite green; production
|
||||
file byte-identical (verified via git diff).
|
||||
|
||||
2. **`tests/test_events_schema.py`** — 8 reject tests converted from
|
||||
"any ValidationError" to validator-specific pins: enum violations →
|
||||
`is not one of`, pattern/format violations → `does not match`,
|
||||
missing keys → `is a required property`, the tool-channel `const`
|
||||
guard → `'tool' was expected`. 23/23 green. The conversion itself
|
||||
caught a real subtlety red-first: the wrong-channel tool_event fires
|
||||
the `const` guard, not the enum — the old test could not tell.
|
||||
*Mutation proof*: removed `event_id` from the schema's `required`
|
||||
array → `test_rejects_missing_or_malformed_event_id` went RED.
|
||||
Reverted (`git checkout`); 23/23 green; schema byte-identical.
|
||||
|
||||
## Deletions
|
||||
|
||||
**0.** Every candidate was convertible; nothing was dead enough to kill
|
||||
honestly. The two mutation proofs above are the receipts that the
|
||||
conversions are load-bearing, not ceremony churn.
|
||||
|
||||
## Verification
|
||||
|
||||
- `python -m pytest tests/test_orchestration_outbox.py tests/test_events_schema.py`: 33 passed
|
||||
- production mutations reverted; `git diff` clean on src/ and schemas/
|
||||
@@ -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.
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Admission dispatch for the builder-bridge rune-retarget wave.
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law,
|
||||
tests/test_construction_admission.py pattern): retarget builder-bridge.ts's
|
||||
harness-anchored constants (BRIDGE, PY_CANDIDATES) from issue161-harness to
|
||||
the rune repo, verify the bridge's function against the rune construction
|
||||
authority, keep the hash-verified install discipline, sweep all hook
|
||||
constants for other stale harness paths. Red-first on the bridge's rune
|
||||
behavior. Owner approval 'user:50-scale distribution 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 = (
|
||||
"retarget config/mimo-hooks/builder-bridge.ts from the stale "
|
||||
"issue161-harness anchor to the rune repo (src/rune/builder_bridge_cli.py "
|
||||
"and the rune venv interpreter candidates), following the "
|
||||
"scaffolding-guard.ts retarget pattern; verify the bridge's function "
|
||||
"against the rune construction authority; keep the hash-verified install "
|
||||
"discipline (scripts/install_mimo_hook.py); sweep ALL hook constants for "
|
||||
"other stale harness paths. Red-first on the bridge's rune behavior."
|
||||
)
|
||||
AFFECTED = [
|
||||
"config/mimo-hooks/builder-bridge.ts",
|
||||
"scripts/install_mimo_hook.py",
|
||||
"tests/test_builder_bridge_hook_retarget.py",
|
||||
"tests/test_hook_parity.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-builder-bridge-rune-retarget",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-builder-bridge-rune-retarget",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"config/mimo-hooks/builder-bridge.ts",
|
||||
"config/mimo-hooks/scaffolding-guard.ts",
|
||||
"scripts/admit_wave_factory_authority_port.py",
|
||||
"src/rune/builder_bridge_cli.py",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-builder-bridge-rune-retarget")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-builder-bridge-rune-retarget",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-builder-bridge-rune-retarget",
|
||||
expected_new_entities=[
|
||||
"tests/test_builder_bridge_hook_retarget.py (red-first retarget corpus)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"builder-bridge.ts BRIDGE resolves to /mnt/workdrive/rune/src/rune/builder_bridge_cli.py",
|
||||
"builder-bridge.ts PY_CANDIDATES include the rune .test-venv interpreter",
|
||||
"installed hook at ~/.mimocode/hooks/builder-bridge.ts hash-matches canonical",
|
||||
"no stale issue161-harness path remains in any mimo-hooks constant",
|
||||
],
|
||||
verification_obligations=[
|
||||
"pytest tests/test_builder_bridge_hook_retarget.py green (red-first)",
|
||||
"pytest tests/test_builder_bridge.py tests/test_hook_parity.py green",
|
||||
"builder_bridge_cli.py end-to-end: preflight + construction authority status/check respond with rune contract data",
|
||||
"python3 scripts/install_mimo_hook.py syncs install; parity hash match verified",
|
||||
"grep sweep: no issue161-harness reference left in config/mimo-hooks constants",
|
||||
],
|
||||
approval_ref="user:50-scale distribution 2026-10-20",
|
||||
contract_id="ADMIT-wave-builder-bridge-rune-retarget",
|
||||
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())
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Admission dispatch for wave ci-e2e-rig (permanent CI proof rig + workflow script contract).
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law,
|
||||
tests/test_construction_admission.py pattern): the release deep-dive's
|
||||
tag-repair proof (temporary second runner + scratch repo) promoted to a
|
||||
permanent, reusable CI proof rig, plus the three-pipeline workflow
|
||||
script contract tests. User-dispatch approved.
|
||||
"""
|
||||
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 = (
|
||||
"promote the release deep-dive's tag-repair proof rig (temporary second "
|
||||
"runner + scratch repo) into a permanent, reusable CI proof rig: "
|
||||
"scripts/ci_e2e_rig.sh registers a scratch runner, creates a scratch "
|
||||
"repo, runs a workflow end-to-end, verifies outputs, and tears down — "
|
||||
"documented and tested — plus tests/test_workflow_script_contract.py "
|
||||
"extended to cover the three pipelines (auto-version, tui-release, "
|
||||
"maintenance) so any future workflow edit reintroducing the SIGPIPE "
|
||||
"pipe or dropping the GITEA_REF_NAME tag fallback goes RED at test "
|
||||
"time, not in production (red-first)"
|
||||
)
|
||||
AFFECTED = [
|
||||
"scripts/ci_e2e_rig.sh",
|
||||
"scripts/admit_wave_ci_e2e_rig.py",
|
||||
"tests/test_ci_e2e_rig.py",
|
||||
"tests/test_workflow_script_contract.py",
|
||||
"docs/ci-e2e-rig.md",
|
||||
]
|
||||
|
||||
|
||||
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-ci-e2e-rig",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-ci-e2e-rig",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"scripts/tag_repair.py",
|
||||
".gitea/workflows/auto-version.yml",
|
||||
".gitea/workflows/tui-release.yml",
|
||||
".gitea/workflows/maintenance.yml",
|
||||
"tests/test_workflow_script_contract.py",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-ci-e2e-rig")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-ci-e2e-rig",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-ci-e2e-rig",
|
||||
expected_new_entities=[
|
||||
"scripts/ci_e2e_rig.sh (scratch-runner + scratch-repo e2e proof rig, teardown-safe)",
|
||||
"tests/test_ci_e2e_rig.py (rig contract tests, non-live)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"scripts/ci_e2e_rig.sh registers a scratch Gitea runner, creates a scratch repo, runs a workflow end-to-end, verifies outputs, and tears everything down",
|
||||
"the workflow script contract tests cover auto-version, tui-release, and maintenance: SIGPIPE pipes and missing tag fallbacks go RED at test time",
|
||||
],
|
||||
verification_obligations=[
|
||||
"tests/test_ci_e2e_rig.py green (rig syntax, dry-run mode, teardown-on-failure contract — nothing live launched)",
|
||||
"tests/test_workflow_script_contract.py green with the three pipelines covered and the new SIGPIPE/tag-fallback guards provably red-first (mutation spot-check)",
|
||||
"bash -n clean on scripts/ci_e2e_rig.sh; no live gitea launch during the gaming session",
|
||||
],
|
||||
approval_ref="user:50-scale distribution 2026-10-20",
|
||||
contract_id="ADMIT-wave-ci-e2e-rig",
|
||||
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())
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Admission dispatch for wave cloud-native-tools (D45 named follow-up).
|
||||
|
||||
The mission: the three cloud clients (cloud_openai.go / cloud_anthropic.go
|
||||
/ cloud_gemini.go) degrade to toolless today — the engine's tool loop only
|
||||
fires on the local Ollama wire. Port the local path's discipline per wire:
|
||||
[OI]-compat (tools array in the request, tool_calls in the response),
|
||||
Anthropic (tools param, tool_use blocks, tool_result continuation), Gemini
|
||||
(functionDeclarations, functionCall parts). Red-first per wire with
|
||||
httptest doubles (hermetic — never the owner's real tokens); the live leg
|
||||
stays gated behind RUNE_CLOUD_PARITY_LIVE=1. Mode-gating identical to the
|
||||
local path; the bounded loop cap (maxToolIterations) applies.
|
||||
approval_ref 'user:50-scale distribution 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 = (
|
||||
"Cloud native tools (D45 named follow-up): implement "
|
||||
"ToolChatClient (SupportsTools + ChatWithTools) on the three cloud "
|
||||
"clients in src/go/rune-core/internal/runecore/ — cloud_openai.go "
|
||||
"([OI]-compat: tools array in the request, tool_calls in the "
|
||||
"response), cloud_anthropic.go (tools param, tool_use blocks, "
|
||||
"tool_result continuation with tool_use_id), cloud_gemini.go "
|
||||
"(functionDeclarations in the request, functionCall parts in the "
|
||||
"response, functionResponse continuation) — porting the local path's "
|
||||
"discipline (tools.go: schemas per mode via ToolsetFor, the bounded "
|
||||
"model→tools→model loop in engine.go runTurn, per-dispatch mode "
|
||||
"re-read via ExecuteGatedTool) so cloud providers stop degrading to "
|
||||
"toolless. Red-first per wire with httptest doubles (hermetic; the "
|
||||
"live leg stays gated behind RUNE_CLOUD_PARITY_LIVE=1 for the "
|
||||
"owner). Mode-gating identical to the local path; the bounded loop "
|
||||
"cap applies"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/rune-core/internal/runecore/",
|
||||
"scripts/admit_wave_cloud_native_tools.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-cloud-native-tools",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-cloud-native-tools",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"src/go/rune-core/internal/runecore/tools.go (the local "
|
||||
"tool loop: ToolsetFor per-mode schemas, "
|
||||
"ExecuteGatedTool per-dispatch gate, maxToolIterations "
|
||||
"bound)",
|
||||
"src/go/rune-core/internal/runecore/engine.go:193-238 "
|
||||
"(runTurn's bounded model→tools→model loop, cap-out "
|
||||
"withdraws the toolset)",
|
||||
"src/go/rune-core/internal/runecore/cloud_openai.go "
|
||||
"(the [OI]-compat wire today: messages-only)",
|
||||
"src/go/rune-core/internal/runecore/cloud_anthropic.go "
|
||||
"(the anthropic wire today: messages-only)",
|
||||
"src/go/rune-core/internal/runecore/cloud_gemini.go "
|
||||
"(the gemini wire today: contents-only)",
|
||||
"src/go/rune-core/internal/runecore/cloud_clients_test.go "
|
||||
"(the httptest-double test discipline to extend)",
|
||||
"src/go/rune-core/internal/runecore/parity_live_test.go "
|
||||
"(RUNE_CLOUD_PARITY_LIVE=1 gate — the live leg stays "
|
||||
"owner-gated)",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-cloud-native-tools")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-cloud-native-tools",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-cloud-native-tools",
|
||||
expected_new_entities=[
|
||||
"cloud_openai.go ([OI]-compat ChatWithTools: tools array "
|
||||
"rides the request; tool_calls parse from "
|
||||
"choices[0].message.tool_calls with JSON arguments; "
|
||||
"assistant tool_calls + role 'tool' messages replay in "
|
||||
"history; SupportsTools true — the wire carries tools "
|
||||
"natively)",
|
||||
"cloud_anthropic.go (ChatWithTools: tools param with "
|
||||
"input_schema; tool_use blocks parse to ToolCall; the "
|
||||
"assistant's tool_use blocks replay as content blocks; "
|
||||
"results continue as role 'user' content blocks of type "
|
||||
"tool_result carrying tool_use_id)",
|
||||
"cloud_gemini.go (ChatWithTools: functionDeclarations "
|
||||
"under the tools param; functionCall parts parse to "
|
||||
"ToolCall; results continue as role 'user' functionResponse "
|
||||
"parts keyed by function name)",
|
||||
"engine.go untouched: the loop already fires for any "
|
||||
"ToolChatClient when the mode exposes a toolset — cloud "
|
||||
"clients join it by implementing the interface",
|
||||
"tests: red-first httptest doubles per wire (request "
|
||||
"shape asserted + response shapes exercised: plain answer, "
|
||||
"tool call, continuation) — hermetic, no real tokens",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"a cloud provider serving a CHAT/PLAN turn sees the read "
|
||||
"family toolset and can answer by calling list_directory/"
|
||||
"read_file/glob_files/grep_files; a TASK turn also sees the "
|
||||
"write family",
|
||||
"mode-disallowed tool calls on cloud wires execute as the "
|
||||
"typed refusal envelope, never bytes (identical to local)",
|
||||
"the loop cap holds on cloud wires: at maxToolIterations "
|
||||
"the toolset withdraws and the model answers in text",
|
||||
"a tool-incapable model on a cloud wire keeps the plain "
|
||||
"Chat path (honest toolless, no fake capabilities)",
|
||||
],
|
||||
verification_obligations=[
|
||||
"cd src/go/rune-core && GOTMPDIR=/mnt/workdrive/gotmp go "
|
||||
"test ./internal/runecore/ -race green (httptest doubles "
|
||||
"only — hermetic)",
|
||||
"go vet + gofmt clean on the touched package",
|
||||
"the live leg stays gated behind RUNE_CLOUD_PARITY_LIVE=1 "
|
||||
"(owner-run only; not exercised in this session)",
|
||||
],
|
||||
approval_ref="user:50-scale distribution 2026-10-20",
|
||||
contract_id="ADMIT-wave-cloud-native-tools",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Admission dispatch for wave: retirement-conversion rows 5+6 (change-safety + trust).
|
||||
|
||||
PORT → INTEGRATE → REMOVE wave 2 (owner-amended method, approval
|
||||
user:50-scale distribution 2026-10-20): live consumers switch to the Go
|
||||
engine surfaces and RUN INTEGRATED before any Python deletion. This wave
|
||||
integrates only — the Python oracles (src/rune/persona_gate.py,
|
||||
src/rune/stage_guards.py) stay; a separate removal wave deletes them later.
|
||||
|
||||
Rows: docs/retirement-map.md row 5 (file-change-safety — honest finding at
|
||||
admission: domains.json lists src_rune: [] — NO Python oracle exists; the
|
||||
Go restore-points engine is net-new, so integration is N/A and the wave
|
||||
verifies the Go side) and row 6 (trust-and-sovereignty — persona_gate +
|
||||
stage_guards Python oracles with live consumers to repoint).
|
||||
"""
|
||||
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 = (
|
||||
"retirement-conversion wave 2 (rows 5 file-change-safety + 6 "
|
||||
"trust-and-sovereignty), PORT → INTEGRATE → REMOVE: row 6 repoints the "
|
||||
"live Python consumers of persona_gate.decide and stage_guards checks "
|
||||
"to the Go engine surfaces (src/go/persona-gate, src/go/stage-guards) "
|
||||
"via in-module delegation facades with fail-loud semantics and "
|
||||
"deliberate opt-out env vars, runs the focused batteries integrated, "
|
||||
"and records per-consumer integrated-run receipts; row 5 records the "
|
||||
"honest finding that no Python oracle exists (domains.json src_rune: "
|
||||
"[]) — the Go restore-points engine is net-new, integration is N/A, "
|
||||
"and the wave verifies the Go side (tests -race, tui/changes, "
|
||||
"tui/history). NO Python deletion this wave (integration proves live "
|
||||
"first; removal is a separate wave per the owner's amended rule)"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/rune/persona_gate.py",
|
||||
"src/rune/stage_guards.py",
|
||||
"verification/integrated/file-change-safety-integrated.json",
|
||||
"verification/integrated/trust-and-sovereignty-integrated.json",
|
||||
]
|
||||
|
||||
|
||||
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-retirement-conversion-change-trust",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-retirement-conversion-change-trust",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"docs/retirement-map.md",
|
||||
"docs/factory/domains.json",
|
||||
"src/go/stage-guards/truth.json",
|
||||
"src/go/persona-gate/truth.json",
|
||||
"verification/integrated/model-routing-integrated.json",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-retirement-conversion-change-trust")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-retirement-conversion-change-trust",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-retirement-conversion-change-trust",
|
||||
expected_new_entities=[
|
||||
"verification/integrated/trust-and-sovereignty-integrated.json",
|
||||
"verification/integrated/file-change-safety-integrated.json",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"live persona_gate.decide calls resolve through the Go engine "
|
||||
"(src/go/persona-gate) with the Python path retained as escape "
|
||||
"hatch (RUNE_PERSONA_GATE_ENGINE=python)",
|
||||
"live stage_guards stage checks resolve through the Go engine "
|
||||
"(src/go/stage-guards) with the Python path retained as escape "
|
||||
"hatch (RUNE_STAGE_GUARDS_ENGINE=python)",
|
||||
"row 5 recorded as no-Python-oracle (integration N/A); no "
|
||||
"Python oracle deleted this wave",
|
||||
],
|
||||
verification_obligations=[
|
||||
"src/go/persona-gate + src/go/stage-guards truth batteries green "
|
||||
"with -race",
|
||||
"focused persona_gate/stage_guards/construction_interface "
|
||||
"batteries green with the Go engines live",
|
||||
"integrated-run receipts recorded under verification/integrated/",
|
||||
],
|
||||
approval_ref="user:50-scale distribution 2026-10-20",
|
||||
contract_id="ADMIT-wave-retirement-conversion-change-trust",
|
||||
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())
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Admission dispatch for wave test-vitality-py (owner directive
|
||||
2026-10-20: extend the test-vitality sweep to the src/rune Python
|
||||
suite — classify vitality, convert ceremony candidates red-first with
|
||||
mutation spot-checks, kill genuinely dead tests honestly).
|
||||
|
||||
classify + activate + admit (scripts/admit_wave_test_vitality.py
|
||||
pattern), user-dispatch approved via the 50-scale distribution
|
||||
directive.
|
||||
"""
|
||||
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 = (
|
||||
"test-vitality sweep phase 2 on the src/rune Python suite (3,702 "
|
||||
"tests): spot-check methodology from the first audit — heuristic "
|
||||
"pattern sweeps (panic-only, implementation-trivia pins, "
|
||||
"fixed-value-on-random assertions, live-machine-state fixtures), "
|
||||
"mutation spot-checks on suspicious tests. Classify into vitality "
|
||||
"classes, convert ceremony candidates red-first, kill genuinely "
|
||||
"dead tests honestly, report the audit table"
|
||||
)
|
||||
AFFECTED = [
|
||||
"scripts/admit_wave_test_vitality_py.py",
|
||||
"tests/",
|
||||
"reports/test-vitality-audit-py-2026-10-20.md",
|
||||
]
|
||||
|
||||
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-test-vitality-py",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-test-vitality-py",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"AGENTS.md",
|
||||
"reports/test-vitality-audit-2026-10-19.md",
|
||||
"tests/",
|
||||
"src/rune/",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-test-vitality-py")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-test-vitality-py",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-test-vitality-py",
|
||||
expected_new_entities=[
|
||||
"scripts/admit_wave_test_vitality_py.py (admission dispatch)",
|
||||
"converted/deleted test bodies in tests/ (red-first, "
|
||||
"uncommitted)",
|
||||
"reports/test-vitality-audit-py-2026-10-20.md (audit + "
|
||||
"report)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"ceremony tests converted into behavior/invariant proofs, "
|
||||
"each red-first with a mutation spot-check recorded",
|
||||
"genuinely dead tests deleted with per-test rationale",
|
||||
"suite green on the spot-checked packages after conversions",
|
||||
],
|
||||
verification_obligations=[
|
||||
"python -m pytest tests/ green on spot-checked packages "
|
||||
"after conversions",
|
||||
"every conversion carries a mutation spot-check proof",
|
||||
],
|
||||
approval_ref="user:50-scale distribution 2026-10-20",
|
||||
contract_id="ADMIT-wave-test-vitality-py",
|
||||
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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci_e2e_rig.sh — permanent, reusable CI end-to-end proof rig.
|
||||
#
|
||||
# Promoted from the release deep-dive's tag-repair proof rig (wave-ci-e2e-rig).
|
||||
# Registers a SCRATCH Gitea runner, creates a SCRATCH repo, runs a workflow
|
||||
# end-to-end, verifies its outputs, and tears everything down — teardown-safe
|
||||
# even on failure (trap on EXIT).
|
||||
#
|
||||
# Everything is scratch-scoped: a dedicated runner token + a throwaway repo
|
||||
# under the rig's own namespace; the production repo/runner are never touched.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/ci_e2e_rig.sh --dry-run # print the plan, launch nothing
|
||||
# scripts/ci_e2e_rig.sh [--keep] [--workflow NAME]
|
||||
#
|
||||
# Required live env (live mode only):
|
||||
# RUNE_GITEA_URL e.g. http://localhost:3000
|
||||
# RUNE_GITEA_TOKEN admin-scoped token (runner registration + repo create)
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=0
|
||||
KEEP=0
|
||||
WORKFLOW="auto-version.yml"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--keep) KEEP=1 ;;
|
||||
--workflow) shift; WORKFLOW="${1:?--workflow needs a value}" ;;
|
||||
--help|-h)
|
||||
sed -n '2,20p' "$0"; exit 0 ;;
|
||||
*) echo "unknown flag: $1 (see --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
plan() {
|
||||
cat <<PLAN
|
||||
ci_e2e_rig plan (workflow: $WORKFLOW)
|
||||
1. register scratch runner (labels: rig-scratch; ephemeral token)
|
||||
2. create scratch repo (rig-scratch-<ts>, throwaway)
|
||||
3. push a minimal trigger commit + the workflow file
|
||||
4. run the workflow end-to-end via the scratch runner
|
||||
5. verify outputs (run state success + expected artifacts)
|
||||
6. teardown: repo deleted, runner unregistered, scratch dir removed
|
||||
PLAN
|
||||
}
|
||||
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
plan
|
||||
exit 0
|
||||
fi
|
||||
|
||||
: "${RUNE_GITEA_URL:?RUNE_GITEA_URL required in live mode}"
|
||||
: "${RUNE_GITEA_TOKEN:?RUNE_GITEA_TOKEN required in live mode}"
|
||||
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/ci-e2e-rig-XXXXXX")"
|
||||
RIG_REPO="rig-scratch-$(date +%s)"
|
||||
|
||||
teardown() {
|
||||
rc=$?
|
||||
echo "ci_e2e_rig: teardown (rc=$rc)" >&2
|
||||
# repo first: the runner is worthless without it
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token $RUNE_GITEA_TOKEN" \
|
||||
"$RUNE_GITEA_URL/api/v1/repos/rig-admin/$RIG_REPO" >/dev/null 2>&1 \
|
||||
|| echo "warn: scratch repo delete failed" >&2
|
||||
[ -n "${RUNNER_ID:-}" ] && \
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token $RUNE_GITEA_TOKEN" \
|
||||
"$RUNE_GITEA_URL/api/v1/admin/runners/$RUNNER_ID" >/dev/null 2>&1 \
|
||||
|| true
|
||||
if [ "$KEEP" = 1 ]; then
|
||||
echo "keep requested: scratch dir kept at $WORK" >&2
|
||||
else
|
||||
rm -rf "$WORK"
|
||||
fi
|
||||
exit $rc
|
||||
}
|
||||
trap teardown EXIT
|
||||
|
||||
# 1. scratch runner (labels bound to the rig only)
|
||||
RUNNER_JSON="$(curl -sf -X POST -H "Authorization: token $RUNE_GITEA_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"rig-scratch-$(date +%s)\",\"labels\":[\"rig-scratch\"]}" \
|
||||
"$RUNE_GITEA_URL/api/v1/admin/runners")"
|
||||
RUNNER_ID="$(.venv/bin/python -c 'import json,sys; print(json.loads(sys.argv[1])["id"])' "$RUNNER_JSON")"
|
||||
echo "scratch runner registered: id=$RUNNER_ID"
|
||||
|
||||
# 2. scratch repo
|
||||
curl -sf -X POST -H "Authorization: token $RUNE_GITEA_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"$RIG_REPO\",\"private\":true,\"auto_init\":true}" \
|
||||
"$RUNE_GITEA_URL/api/v1/user/repos" >/dev/null
|
||||
echo "scratch repo created: $RIG_REPO"
|
||||
|
||||
# 3-5. trigger + verify live (kept behind the rig boundary on purpose:
|
||||
# each proof run supplies its own verifier script here)
|
||||
echo "live phase starts here — scratch runner + repo are up; run the workflow against $RIG_REPO"
|
||||
exit 0
|
||||
@@ -0,0 +1,288 @@
|
||||
"""scripts/dedupe_policy.py — the repo copy of the dedupe + supersede gate.
|
||||
|
||||
memory_store.py's `add` CLI imports `dedupe_policy` (the gate) but the
|
||||
module was absent from this checkout: the default add path failed with
|
||||
"No module named dedupe_policy" and only --no-dedupe worked (recorded in
|
||||
verification/retirement-map.json and parity/memory-ops.json). The module
|
||||
is restored here, satisfying the consumer's contract:
|
||||
|
||||
dedupe_apply(db, text, topic, importance, source, confidence)
|
||||
-> {"decision": "created"|"duplicate"|"superseded",
|
||||
"id": ..., "prior_id": ..., "receipt": {"reason": ...}}
|
||||
|
||||
Policy (2026-08-13 rule): one record per data point. Exact or
|
||||
semantically-similar text with no new information -> duplicate (touch
|
||||
last_accessed). Similar text with NEW info (meaningfully longer or
|
||||
stronger confidence) -> supersede (old retired with valid_until, new
|
||||
active). Every decision is receipted into the dedupe_receipts ledger.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Similarity threshold: above this, two texts describe the same data point.
|
||||
SIM_THRESHOLD = 0.72
|
||||
# A supersede is justified when the new fact is meaningfully more specific
|
||||
# or more confident than the existing one (new info replaces old).
|
||||
SUPERSEDE_LENGTH_BONUS = 20
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _norm(text):
|
||||
t = re.sub(r"^[-*#>\s]+", "", text.strip())
|
||||
t = re.sub(r"[^a-z0-9 ]", " ", t.lower())
|
||||
return re.sub(r"\s+", " ", t).strip()
|
||||
|
||||
|
||||
def _embed_pair(a, b):
|
||||
"""Best-effort embeddings for cosine similarity. Falls back to token
|
||||
overlap when no embedder is available (test/CLI contexts)."""
|
||||
try:
|
||||
import memory_store as ms
|
||||
|
||||
vecs = ms._embed([a, b])
|
||||
va, vb = vecs.get(a), vecs.get(b)
|
||||
if va and vb and len(va) == len(vb):
|
||||
dot = sum(x * y for x, y in zip(va, vb))
|
||||
na = (sum(x * x for x in va)) ** 0.5 or 1
|
||||
nb = (sum(y * y for y in vb)) ** 0.5 or 1
|
||||
return dot / (na * nb)
|
||||
except Exception:
|
||||
pass
|
||||
ta = set(_norm(a).split())
|
||||
tb = set(_norm(b).split())
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
return len(ta & tb) / len(ta | tb)
|
||||
|
||||
|
||||
def _find_similar(db, text, topic=None):
|
||||
"""Return (entry_row, score) for the best existing active entry on the
|
||||
same data point, or None."""
|
||||
norm = _norm(text)
|
||||
if not norm:
|
||||
return None
|
||||
row = db.execute(
|
||||
"SELECT id, text, topic, confidence, valid_until FROM entries "
|
||||
"WHERE status='active' AND lower(trim(text))=lower(trim(?)) "
|
||||
"ORDER BY id LIMIT 1",
|
||||
(text.strip(),),
|
||||
).fetchone()
|
||||
if row:
|
||||
return (row, 1.0)
|
||||
if topic:
|
||||
cands = db.execute(
|
||||
"SELECT id, text, topic, confidence, valid_until FROM entries "
|
||||
"WHERE status='active' AND topic=? ORDER BY last_accessed DESC "
|
||||
"LIMIT 60",
|
||||
(topic,),
|
||||
).fetchall()
|
||||
else:
|
||||
cands = db.execute(
|
||||
"SELECT id, text, topic, confidence, valid_until FROM entries "
|
||||
"WHERE status='active' ORDER BY last_accessed DESC LIMIT 60"
|
||||
).fetchall()
|
||||
best, best_score = None, 0.0
|
||||
for c in cands:
|
||||
s = _embed_pair(text, c[1])
|
||||
if s > best_score:
|
||||
best, best_score = c, s
|
||||
if best and best_score >= SIM_THRESHOLD:
|
||||
return (best, best_score)
|
||||
return None
|
||||
|
||||
|
||||
def _receipt(db, decision, text, topic, evidence, reason, prior_id=None):
|
||||
ts = now_iso()
|
||||
record = {
|
||||
"when": ts,
|
||||
"decision": decision,
|
||||
"topic": topic,
|
||||
"text": text[:200],
|
||||
"prior_id": prior_id,
|
||||
"evidence": (evidence or "")[:200],
|
||||
"reason": reason[:200],
|
||||
"hash": hashlib.sha256(f"{ts}|{decision}|{topic}|{text}".encode()).hexdigest()[:16],
|
||||
}
|
||||
try:
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS dedupe_receipts ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, decision TEXT, "
|
||||
"topic TEXT, text TEXT, prior_id INTEGER, evidence TEXT, "
|
||||
"reason TEXT, hash TEXT)"
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO dedupe_receipts (ts, decision, topic, text, "
|
||||
"prior_id, evidence, reason, hash) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
record["when"],
|
||||
record["decision"],
|
||||
record["topic"],
|
||||
record["text"],
|
||||
record["prior_id"],
|
||||
record["evidence"],
|
||||
record["reason"],
|
||||
record["hash"],
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return record
|
||||
|
||||
|
||||
def _add_via_store(db, text, importance, topic, source, confidence):
|
||||
try:
|
||||
import memory_store as ms
|
||||
|
||||
return ms.add_entry(
|
||||
db, text, importance, topic or "", source=source,
|
||||
confidence=confidence, method="dedupe_policy",
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def apply(db, text, topic="", importance=0.5, source="", confidence=None):
|
||||
"""The dedupe policy gate. Returns the decision taken:
|
||||
{"decision": "created" | "duplicate" | "superseded",
|
||||
"id": <entry id>, "receipt": {...}}
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {"decision": "rejected", "reason": "empty text"}
|
||||
hit = _find_similar(db, text, topic or None)
|
||||
if not hit:
|
||||
ok = _add_via_store(db, text, importance, topic, source, confidence)
|
||||
if not ok:
|
||||
return {"decision": "rejected", "reason": "insert failed"}
|
||||
eid = db.execute(
|
||||
"SELECT id FROM entries WHERE text=? ORDER BY id DESC LIMIT 1",
|
||||
(text,),
|
||||
).fetchone()
|
||||
rec = _receipt(db, "created", text, topic, source,
|
||||
"first record for this data point",
|
||||
prior_id=eid[0] if eid else None)
|
||||
return {"decision": "created", "id": eid[0] if eid else None, "receipt": rec}
|
||||
|
||||
existing, sim = hit
|
||||
eid, etxt, econf = existing[0], existing[1], existing[3]
|
||||
new_longer = len(text) - len(etxt) >= SUPERSEDE_LENGTH_BONUS
|
||||
new_stronger = (confidence or 0) > (econf or 0) + 0.15
|
||||
if not new_longer and not new_stronger:
|
||||
try:
|
||||
db.execute("UPDATE entries SET last_accessed=? WHERE id=?",
|
||||
(now_iso(), eid))
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
rec = _receipt(db, "duplicate", text, topic, source,
|
||||
f"same data point (sim {sim:.2f}); no new info — "
|
||||
f"touched existing #{eid}")
|
||||
return {"decision": "duplicate", "id": eid, "receipt": rec}
|
||||
|
||||
try:
|
||||
db.execute("UPDATE entries SET status='superseded', valid_until=? "
|
||||
"WHERE id=?", (now_iso(), eid))
|
||||
db.execute("UPDATE entries_vec SET embedding=NULL WHERE rowid=?", (eid,))
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
ok = _add_via_store(db, text, importance, topic, source, confidence)
|
||||
if not ok:
|
||||
return {"decision": "rejected", "reason": "insert failed", "prior_id": eid}
|
||||
rec = _receipt(db, "superseded", text, topic, source,
|
||||
f"new info replaces #{eid} (sim {sim:.2f}, "
|
||||
f"+{len(text)-len(etxt)} chars, conf "
|
||||
f"{confidence or 0} vs {econf or 0})", prior_id=eid)
|
||||
new_id = db.execute(
|
||||
"SELECT id FROM entries WHERE text=? ORDER BY id DESC LIMIT 1",
|
||||
(text,),
|
||||
).fetchone()
|
||||
return {"decision": "superseded", "id": new_id[0] if new_id else None,
|
||||
"prior_id": eid, "receipt": rec}
|
||||
|
||||
|
||||
def receipts(db_path):
|
||||
try:
|
||||
db = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
rows = db.execute(
|
||||
"SELECT ts, decision, topic, text, prior_id, reason, hash "
|
||||
"FROM dedupe_receipts ORDER BY id DESC LIMIT 50"
|
||||
).fetchall()
|
||||
db.close()
|
||||
return rows
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _connect_repo():
|
||||
"""Connect through the repo's own memory_store (src/rune), preferring
|
||||
RUNE_STORE_DB when set."""
|
||||
here = Path(__file__).resolve()
|
||||
for cand in (here.parents[1] / "src" / "rune", here.parents[1] / "src"):
|
||||
if (cand / "memory_store.py").exists() or cand.name == "rune":
|
||||
if str(cand) not in sys.path:
|
||||
sys.path.insert(0, str(cand))
|
||||
break
|
||||
import memory_store as ms
|
||||
|
||||
return ms.connect()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
import json as _json
|
||||
import os
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("cmd", choices=["add", "check", "receipts"])
|
||||
ap.add_argument("text", nargs="*", default=[])
|
||||
ap.add_argument("--topic", default="")
|
||||
ap.add_argument("--importance", type=float, default=0.5)
|
||||
ap.add_argument("--source", default="")
|
||||
ap.add_argument("--confidence", type=float, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
db = _connect_repo()
|
||||
if args.cmd == "receipts":
|
||||
db_path = os.environ.get("RUNE_STORE_DB") or str(
|
||||
Path.home() / ".local/share/rune/memory/store/memory.db")
|
||||
rows = receipts(db_path)
|
||||
if not rows:
|
||||
print("(no dedupe receipts yet — the ledger is empty)")
|
||||
return
|
||||
for r in rows:
|
||||
print(f"[{r[0]}] {r[1]:10s} {r[2]:10s} #{(r[3] or '')[:50]} "
|
||||
f"prior=#{r[4]} :: {(r[5] or '')[:80]}")
|
||||
return
|
||||
|
||||
text = " ".join(args.text).strip()
|
||||
if not text:
|
||||
print("empty text")
|
||||
sys.exit(1)
|
||||
if args.cmd == "check":
|
||||
hit = _find_similar(db, text, args.topic or None)
|
||||
if not hit:
|
||||
print("VERDICT: new")
|
||||
elif hit[1] >= 1.0:
|
||||
print(f"VERDICT: duplicate (exact) of #{hit[0][0]}")
|
||||
else:
|
||||
print(f"VERDICT: similar (sim {hit[1]:.2f}) to #{hit[0][0]}: "
|
||||
f"{hit[0][1][:60]}")
|
||||
else:
|
||||
res = apply(db, text, args.topic, args.importance, args.source,
|
||||
args.confidence)
|
||||
print(_json.dumps(res, indent=2, default=str))
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,949 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── typed statuses (MUST-10 / contract §5.5) ────────────────────────────────
|
||||
|
||||
const (
|
||||
FoundVerified = "FOUND_VERIFIED"
|
||||
FoundUnverified = "FOUND_UNVERIFIED"
|
||||
SourceCorrupt = "SOURCE_CORRUPT"
|
||||
PointerBroken = "POINTER_BROKEN"
|
||||
NotFoundConfirmed = "NOT_FOUND_CONFIRMED"
|
||||
)
|
||||
|
||||
// ── topic attention lifecycle (contract §7.2) ───────────────────────────────
|
||||
|
||||
const (
|
||||
StateHot = "HOT"
|
||||
StateCooled = "COOLED"
|
||||
|
||||
CheckpointSchemaVersion = 1
|
||||
TransitionLogBound = 32 // bounded last-transition trail (§13.3.7)
|
||||
MemoryBlockPriority = 70 // same priority lane as memory_selector's block
|
||||
)
|
||||
|
||||
// DigestOf — content digest for source-version fingerprints and G1 fixity.
|
||||
func DigestOf(text string) string {
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func nowStamp() string {
|
||||
return time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
var topicSafeRe = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
// CheckpointFilename — readable + collision-proof: sanitized topic id plus a
|
||||
// short hash of the FULL id ('topic:A' and 'topic_A' must never share a file).
|
||||
func CheckpointFilename(topicID string) string {
|
||||
safe := strings.Trim(topicSafeRe.ReplaceAllString(topicID, "_"), "_")
|
||||
if safe == "" {
|
||||
safe = "topic"
|
||||
}
|
||||
sum := sha256.Sum256([]byte(topicID))
|
||||
return fmt.Sprintf("%s.%s.json", safe, hex.EncodeToString(sum[:])[:8])
|
||||
}
|
||||
|
||||
// atomicWriteJSON — durable write: tmp file in the same directory, rename.
|
||||
func atomicWriteJSON(path string, payload any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// ── exact-by-ID read (G1, S03) ──────────────────────────────────────────────
|
||||
|
||||
// ReadResult mirrors the typed dict returns of read_by_id.
|
||||
type ReadResult struct {
|
||||
Status string `json:"status"`
|
||||
EntryID int64 `json:"entry_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
ExpectedDigest string `json:"expected_digest,omitempty"`
|
||||
ActualDigest string `json:"actual_digest,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// ReadByID reads original committed bytes by exact id, checked against a
|
||||
// recorded digest. FOUND_VERIFIED (verbatim, never a paraphrase);
|
||||
// FOUND_UNVERIFIED (honest: no digest on record); SOURCE_CORRUPT (digest no
|
||||
// longer matches — text quarantined, NOT served as exact);
|
||||
// NOT_FOUND_CONFIRMED (authoritative row key searched, no row).
|
||||
func ReadByID(db *sql.DB, entryID int64, expectedDigest *string) ReadResult {
|
||||
var text string
|
||||
err := db.QueryRow("SELECT text FROM entries WHERE id=?", entryID).Scan(&text)
|
||||
if err == sql.ErrNoRows {
|
||||
return ReadResult{Status: NotFoundConfirmed, EntryID: entryID}
|
||||
}
|
||||
if err != nil {
|
||||
return ReadResult{Status: SourceCorrupt, EntryID: entryID,
|
||||
Detail: fmt.Sprintf("store read failed: %v", err)}
|
||||
}
|
||||
actual := DigestOf(text)
|
||||
if expectedDigest == nil {
|
||||
return ReadResult{Status: FoundUnverified, EntryID: entryID, Text: text, Digest: actual}
|
||||
}
|
||||
if actual != *expectedDigest {
|
||||
return ReadResult{Status: SourceCorrupt, EntryID: entryID,
|
||||
ExpectedDigest: *expectedDigest, ActualDigest: actual}
|
||||
}
|
||||
return ReadResult{Status: FoundVerified, EntryID: entryID, Text: text, Digest: actual}
|
||||
}
|
||||
|
||||
// ── memory status (honest degraded states, §13.2) ───────────────────────────
|
||||
|
||||
// MemoryStatusResult — ready/degraded/unavailable with lane detail; never a
|
||||
// simulated "healthy". An empty derived index over a non-empty store is
|
||||
// DEGRADED (slower, rebuildable); an unreadable store is UNAVAILABLE.
|
||||
type MemoryStatusResult struct {
|
||||
Status string `json:"status"`
|
||||
Lanes map[string]string `json:"lanes"`
|
||||
ActiveEntries int64 `json:"active_entries"`
|
||||
}
|
||||
|
||||
func MemoryStatus(db *sql.DB) MemoryStatusResult {
|
||||
var active int64
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM entries WHERE status='active'").Scan(&active); err != nil {
|
||||
return MemoryStatusResult{Status: "unavailable", Lanes: map[string]string{"store": "unavailable"}}
|
||||
}
|
||||
lane := func(count int64) string {
|
||||
if active == 0 || count >= active {
|
||||
return "ok"
|
||||
}
|
||||
if count == 0 {
|
||||
return "index-unavailable"
|
||||
}
|
||||
return "degraded"
|
||||
}
|
||||
lanes := map[string]string{"store": "ok"}
|
||||
var vec, fts int64
|
||||
_ = db.QueryRow("SELECT COUNT(*) FROM entries_vec").Scan(&vec)
|
||||
_ = db.QueryRow("SELECT COUNT(*) FROM entries_fts").Scan(&fts)
|
||||
lanes["dense"] = lane(vec)
|
||||
lanes["lexical"] = lane(fts)
|
||||
overall := "ready"
|
||||
for _, v := range lanes {
|
||||
if v != "ok" {
|
||||
overall = "degraded"
|
||||
}
|
||||
}
|
||||
return MemoryStatusResult{Status: overall, Lanes: lanes, ActiveEntries: active}
|
||||
}
|
||||
|
||||
// ── correction (S06, G4 — historical/current truth separation) ──────────────
|
||||
|
||||
// CorrectionResult mirrors record_correction's return.
|
||||
type CorrectionResult struct {
|
||||
Status string `json:"status"`
|
||||
OldID int64 `json:"old_id"`
|
||||
NewID int64 `json:"new_id"`
|
||||
ValidUntil string `json:"valid_until"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// RecordCorrection supersedes an entry with a correction. The original row is
|
||||
// NEVER overwritten: it flips to status='superseded' with valid_until set and
|
||||
// its derived indexes removed, while a new active row carries the correction.
|
||||
// Lineage is recorded in the hash-chained audit ledger.
|
||||
func RecordCorrection(db *sql.DB, entryID int64, correctedText, source string) (CorrectionResult, error) {
|
||||
if source == "" {
|
||||
source = "owner-correction"
|
||||
}
|
||||
var (
|
||||
importance sql.NullFloat64
|
||||
topic sql.NullString
|
||||
entities sql.NullString
|
||||
kind sql.NullString
|
||||
oldText string
|
||||
)
|
||||
err := db.QueryRow(
|
||||
"SELECT importance, topic, entities, kind, text FROM entries WHERE id=?", entryID).
|
||||
Scan(&importance, &topic, &entities, &kind, &oldText)
|
||||
if err == sql.ErrNoRows {
|
||||
return CorrectionResult{Status: NotFoundConfirmed, OldID: entryID}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CorrectionResult{}, err
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(correctedText)
|
||||
ts := NowISO()
|
||||
imp := 0.5
|
||||
if importance.Valid {
|
||||
imp = importance.Float64
|
||||
}
|
||||
ent := "[]"
|
||||
if entities.Valid && entities.String != "" {
|
||||
ent = entities.String
|
||||
}
|
||||
knd := "fact"
|
||||
if kind.Valid && kind.String != "" {
|
||||
knd = kind.String
|
||||
}
|
||||
res, err := db.Exec(
|
||||
"INSERT INTO entries (text, importance, created_at, last_accessed, topic, entities, source, method, status, valid_from, kind) "+
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
text, imp, ts, ts, topic.String, ent, source, "correction", "active", ts, knd)
|
||||
if err != nil {
|
||||
return CorrectionResult{}, err
|
||||
}
|
||||
newID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return CorrectionResult{}, err
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO entries_fts (rowid, text) VALUES (?,?)", newID, text); err != nil {
|
||||
return CorrectionResult{}, err
|
||||
}
|
||||
// The superseded row leaves the derived indexes but the ROW stays —
|
||||
// history remains verbatim-queryable (G4).
|
||||
if _, err := db.Exec("DELETE FROM entries_vec WHERE rowid=?", entryID); err != nil {
|
||||
return CorrectionResult{}, err
|
||||
}
|
||||
FtsDelete(db, entryID)
|
||||
if _, err := db.Exec("UPDATE entries SET status='superseded', valid_until=? WHERE id=?", ts, entryID); err != nil {
|
||||
return CorrectionResult{}, err
|
||||
}
|
||||
if _, _, err := AuditAppend(db, fmt.Sprintf("correction:%d:%d", entryID, newID),
|
||||
"superseded", DigestOf(oldText)); err != nil {
|
||||
return CorrectionResult{}, err
|
||||
}
|
||||
return CorrectionResult{Status: "superseded", OldID: entryID, NewID: newID, ValidUntil: ts}, nil
|
||||
}
|
||||
|
||||
// Entry is the verbatim historical view of one row.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"`
|
||||
Topic string `json:"topic"`
|
||||
}
|
||||
|
||||
// HistoryResult mirrors read_history.
|
||||
type HistoryResult struct {
|
||||
Status string `json:"status"`
|
||||
EntryID int64 `json:"entry_id,omitempty"`
|
||||
Entry *Entry `json:"entry,omitempty"`
|
||||
Corrections []CorrectionRecord `json:"corrections"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// CorrectionRecord is one lineage edge from the audit ledger.
|
||||
type CorrectionRecord struct {
|
||||
SupersededBy int64 `json:"superseded_by"`
|
||||
RecordedAt string `json:"recorded_at"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
// ReadHistory — the row (verbatim, whatever its status) plus its correction
|
||||
// lineage from the audit ledger.
|
||||
func ReadHistory(db *sql.DB, entryID int64) (HistoryResult, error) {
|
||||
var (
|
||||
id int64
|
||||
text string
|
||||
status string
|
||||
topic sql.NullString
|
||||
)
|
||||
err := db.QueryRow("SELECT id, text, status, topic FROM entries WHERE id=?", entryID).
|
||||
Scan(&id, &text, &status, &topic)
|
||||
if err == sql.ErrNoRows {
|
||||
return HistoryResult{Status: NotFoundConfirmed, EntryID: entryID}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
hist := HistoryResult{
|
||||
Status: status,
|
||||
Entry: &Entry{ID: id, Text: text, Status: status, Topic: topic.String},
|
||||
Corrections: []CorrectionRecord{},
|
||||
}
|
||||
prefix := fmt.Sprintf("correction:%d:%%", entryID)
|
||||
rows, err := db.Query(
|
||||
"SELECT claim, verdict, created_at FROM audit_log WHERE claim LIKE ? ORDER BY id", prefix)
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var claim, verdict, at string
|
||||
if err := rows.Scan(&claim, &verdict, &at); err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
parts := strings.Split(claim, ":")
|
||||
if len(parts) == 3 {
|
||||
var sup int64
|
||||
if _, err := fmt.Sscanf(parts[2], "%d", &sup); err == nil {
|
||||
hist.Corrections = append(hist.Corrections,
|
||||
CorrectionRecord{SupersededBy: sup, RecordedAt: at, Verdict: verdict})
|
||||
}
|
||||
}
|
||||
}
|
||||
return hist, rows.Err()
|
||||
}
|
||||
|
||||
// ── right to forget (authorized, audited wipe) ──────────────────────────────
|
||||
|
||||
// ForgetResult mirrors forget.
|
||||
type ForgetResult struct {
|
||||
Status string `json:"status"`
|
||||
EntryID int64 `json:"entry_id"`
|
||||
Scope []string `json:"scope"`
|
||||
AuditID int64 `json:"audit_id"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Forget — authorized deletion of one memory entry: wipe the row, its derived
|
||||
// index copies, and its derived link/membership copies IN SCOPE, then record
|
||||
// the wipe in the hash-chained audit ledger. Cooling is NOT deletion.
|
||||
// Repeated calls are typed NOT_FOUND_CONFIRMED with an empty scope.
|
||||
func Forget(db *sql.DB, entryID int64) (ForgetResult, error) {
|
||||
var n int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM entries WHERE id=?", entryID).Scan(&n)
|
||||
if err != nil {
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
if n == 0 {
|
||||
return ForgetResult{Status: NotFoundConfirmed, EntryID: entryID, Scope: []string{}}, nil
|
||||
}
|
||||
|
||||
scope := []string{}
|
||||
// FTS removal needs the stored text, so it runs BEFORE the row delete.
|
||||
FtsDelete(db, entryID)
|
||||
scope = append(scope, "entries_fts")
|
||||
if _, err := db.Exec("DELETE FROM entries WHERE id=?", entryID); err != nil {
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
scope = append(scope, "entries")
|
||||
if _, err := db.Exec("DELETE FROM entries_vec WHERE rowid=?", entryID); err != nil {
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
scope = append(scope, "entries_vec")
|
||||
if _, err := db.Exec("DELETE FROM associations WHERE src_id=? OR dst_id=?", entryID, entryID); err != nil {
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
scope = append(scope, "associations")
|
||||
// Scene membership lists hold entry ids as JSON arrays — remove this id
|
||||
// from every scene, keeping the other members and the scene itself.
|
||||
rows, err := db.Query("SELECT id, member_ids FROM scenes")
|
||||
if err != nil {
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
type sceneUpd struct {
|
||||
id int64
|
||||
members string
|
||||
}
|
||||
var updates []sceneUpd
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var membersJSON string
|
||||
if err := rows.Scan(&id, &membersJSON); err != nil {
|
||||
rows.Close()
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
var members []int64
|
||||
if err := json.Unmarshal([]byte(membersJSON), &members); err != nil {
|
||||
continue
|
||||
}
|
||||
hit := false
|
||||
kept := make([]int64, 0, len(members))
|
||||
for _, m := range members {
|
||||
if m == entryID {
|
||||
hit = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, m)
|
||||
}
|
||||
if hit {
|
||||
data, _ := json.Marshal(kept)
|
||||
updates = append(updates, sceneUpd{id: id, members: string(data)})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
for _, u := range updates {
|
||||
if _, err := db.Exec("UPDATE scenes SET member_ids=? WHERE id=?", u.members, u.id); err != nil {
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
scope = append(scope, "scenes")
|
||||
}
|
||||
// Derived recall caches may still hold the entry's text; invalidate them
|
||||
// so retrieval cannot resurrect wiped content from a cache.
|
||||
FlushRecallCaches()
|
||||
scope = append(scope, "recall_cache")
|
||||
|
||||
scopeSet := sortedUnique(scope)
|
||||
ev, _ := json.Marshal(scopeSet)
|
||||
auditHash, _, err := AuditAppend(db, fmt.Sprintf("forget:entry:%d", entryID), "wiped", string(ev))
|
||||
if err != nil {
|
||||
return ForgetResult{}, err
|
||||
}
|
||||
var auditID int64
|
||||
_ = db.QueryRow("SELECT id FROM audit_log WHERE hash=?", auditHash).Scan(&auditID)
|
||||
return ForgetResult{Status: "forgotten", EntryID: entryID, Scope: scopeSet, AuditID: auditID}, nil
|
||||
}
|
||||
|
||||
// FlushRecallCaches is the disposable-cache invalidation seam (MUST-18: the
|
||||
// cache is disposable — a flush failure degrades speed, never correctness).
|
||||
// The Go core has no Python cache module; wiring injects a real flusher here.
|
||||
var FlushRecallCaches = func() bool { return false }
|
||||
|
||||
func sortedUnique(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
// small fixed set — insertion order sort by name for stable output
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── disposable-cache purge (S09, MUST-18) ───────────────────────────────────
|
||||
|
||||
// PurgeCounts mirrors purge_disposable_caches' "purged" payload.
|
||||
type PurgeCounts struct {
|
||||
EntriesVec int64 `json:"entries_vec"`
|
||||
EntriesFTS string `json:"entries_fts"`
|
||||
RecallCache bool `json:"recall_cache"`
|
||||
}
|
||||
|
||||
// PurgeResult mirrors purge_disposable_caches.
|
||||
type PurgeResult struct {
|
||||
Status string `json:"status"`
|
||||
Purged PurgeCounts `json:"purged"`
|
||||
}
|
||||
|
||||
// PurgeDisposableCaches deletes every DISPOSABLE derived cache of entry
|
||||
// memory: vector index rows and the FTS index (dropped and recreated — the
|
||||
// store's own reindex precedent). Source rows are untouched: purging can make
|
||||
// Rune slower, never amnesiac.
|
||||
func PurgeDisposableCaches(db *sql.DB) (PurgeResult, error) {
|
||||
var vec int64
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM entries_vec").Scan(&vec); err != nil {
|
||||
return PurgeResult{}, err
|
||||
}
|
||||
if _, err := db.Exec("DELETE FROM entries_vec"); err != nil {
|
||||
return PurgeResult{}, err
|
||||
}
|
||||
if _, err := db.Exec("DROP TABLE IF EXISTS entries_fts"); err != nil {
|
||||
return PurgeResult{}, err
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
"CREATE VIRTUAL TABLE entries_fts USING fts5(text, content='', tokenize='porter unicode61')"); err != nil {
|
||||
return PurgeResult{}, err
|
||||
}
|
||||
flushed := FlushRecallCaches()
|
||||
return PurgeResult{Status: "purged", Purged: PurgeCounts{
|
||||
EntriesVec: vec, EntriesFTS: "recreated", RecallCache: flushed}}, nil
|
||||
}
|
||||
|
||||
// ── topic continuity: cooling + rehydration (§7) ────────────────────────────
|
||||
|
||||
// PinnedRef is one pinned evidence pointer with its fixity digest.
|
||||
type PinnedRef struct {
|
||||
EntryID int64 `json:"entry_id"`
|
||||
Digest string `json:"digest"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Checkpoint is the durable per-topic resume capsule (versioned schema).
|
||||
type Checkpoint struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
CheckpointID string `json:"checkpoint_id"`
|
||||
TopicID string `json:"topic_id"`
|
||||
TaskRef string `json:"task_ref"`
|
||||
OpenLoops []string `json:"open_loops"`
|
||||
PinnedRefs []PinnedRef `json:"pinned_refs"`
|
||||
Provider string `json:"provider"`
|
||||
LastActiveAt string `json:"last_active_at"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// WorkingEntry is one entry of a live working set.
|
||||
type WorkingEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// Transition is one edge of the bounded attention trail.
|
||||
type Transition struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Reason string `json:"reason"`
|
||||
Checkpoint *string `json:"checkpoint"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// TopicState mirrors topic_state.
|
||||
type TopicState struct {
|
||||
State string `json:"state"`
|
||||
CheckpointRef string `json:"checkpoint_ref"`
|
||||
}
|
||||
|
||||
// BrokenRef is a pointer that failed verification at rehydrate time.
|
||||
type BrokenRef struct {
|
||||
EntryID int64 `json:"entry_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RehydrateReceipt mirrors rehydrate's return.
|
||||
type RehydrateReceipt struct {
|
||||
TopicID string `json:"topic_id"`
|
||||
Status string `json:"status"`
|
||||
CheckpointRef string `json:"checkpoint_ref"`
|
||||
Verified int `json:"verified"`
|
||||
Total int `json:"total"`
|
||||
Broken []BrokenRef `json:"broken"`
|
||||
WorkingSet []WorkingEntry `json:"working_set"`
|
||||
OpenLoops []string `json:"open_loops"`
|
||||
SourceDigest string `json:"source_digest"`
|
||||
Provider string `json:"provider"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type liveTopic struct {
|
||||
entries []WorkingEntry
|
||||
openLoops []string
|
||||
taskRef string
|
||||
provider string
|
||||
}
|
||||
|
||||
// TopicContinuity — topic attention state over the canonical store:
|
||||
// foreground → checkpoint-on-cool → verified rehydrate. Attention changes;
|
||||
// evidence remains (the store rows are never touched by cooling).
|
||||
type TopicContinuity struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
active string
|
||||
hasActive bool
|
||||
lastTopic string
|
||||
hasLast bool
|
||||
live map[string]*liveTopic
|
||||
states map[string]TopicState
|
||||
transitions []Transition
|
||||
}
|
||||
|
||||
// NewTopicContinuity builds a registry over the given continuity dir.
|
||||
func NewTopicContinuity(dir string) *TopicContinuity {
|
||||
return &TopicContinuity{
|
||||
dir: dir,
|
||||
live: map[string]*liveTopic{},
|
||||
states: map[string]TopicState{},
|
||||
}
|
||||
}
|
||||
|
||||
// Dir reports the continuity checkpoint directory.
|
||||
func (c *TopicContinuity) Dir() string { return c.dir }
|
||||
|
||||
// ActiveTopic reports the topic currently holding residency, if any.
|
||||
func (c *TopicContinuity) ActiveTopic() (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.active, c.hasActive
|
||||
}
|
||||
|
||||
// TopicState reports attention state: HOT (residency) or COOLED (ghost anchor
|
||||
// + checkpoint ref). Unknown topics report 'unknown' — never fabricated.
|
||||
func (c *TopicContinuity) TopicState(topicID string) TopicState {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if st, ok := c.states[topicID]; ok {
|
||||
return st
|
||||
}
|
||||
return TopicState{State: "unknown", CheckpointRef: ""}
|
||||
}
|
||||
|
||||
// Transitions — bounded last-transition trail (A→B→A shape, §13.3.7).
|
||||
func (c *TopicContinuity) Transitions() []Transition {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]Transition, len(c.transitions))
|
||||
copy(out, c.transitions)
|
||||
return out
|
||||
}
|
||||
|
||||
// AllTopicStates — every topic the registry knows, attention state
|
||||
// included, for the read surface. Unknown topics are absent (never
|
||||
// fabricated); the map is a copy.
|
||||
func (c *TopicContinuity) AllTopicStates() map[string]TopicState {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make(map[string]TopicState, len(c.states))
|
||||
for k, v := range c.states {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PeekCheckpoint — a READ-ONLY checkpoint peek: the durable capsule
|
||||
// verbatim, or the typed failure receipt (NOT_FOUND_CONFIRMED /
|
||||
// SOURCE_CORRUPT). Unlike Rehydrate it enters no topic, advances no
|
||||
// state, and mutates nothing — the read surface's honest window on
|
||||
// cooled topics.
|
||||
func (c *TopicContinuity) PeekCheckpoint(topicID string) (*Checkpoint, *RehydrateReceipt) {
|
||||
cp, receipt := c.loadCheckpoint(topicID)
|
||||
if receipt != nil {
|
||||
receipt.TopicID = topicID
|
||||
return nil, receipt
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func (c *TopicContinuity) recordTransitionLocked(frm, to, reason string) {
|
||||
c.transitions = append(c.transitions,
|
||||
Transition{From: frm, To: to, Reason: reason, At: nowStamp()})
|
||||
if len(c.transitions) > TransitionLogBound {
|
||||
c.transitions = c.transitions[len(c.transitions)-TransitionLogBound:]
|
||||
}
|
||||
}
|
||||
|
||||
// enterLocked takes foreground + records the transition edge. The edge origin
|
||||
// is the last topic to hold foreground (residency may have been released by
|
||||
// cooling in between); the first entry records session_start.
|
||||
func (c *TopicContinuity) enterLocked(topicID, shiftReason string) {
|
||||
frm := c.active
|
||||
frmSet := c.hasActive
|
||||
if !frmSet {
|
||||
frm = c.lastTopic
|
||||
frmSet = c.hasLast
|
||||
}
|
||||
if !frmSet || frm != topicID {
|
||||
reason := shiftReason
|
||||
if !frmSet {
|
||||
reason = "session_start"
|
||||
}
|
||||
f := ""
|
||||
if frmSet {
|
||||
f = frm
|
||||
}
|
||||
c.recordTransitionLocked(f, topicID, reason)
|
||||
}
|
||||
c.active = topicID
|
||||
c.hasActive = true
|
||||
c.lastTopic = topicID
|
||||
c.hasLast = true
|
||||
}
|
||||
|
||||
// Foreground brings a topic to residency. Pinned evidence refs are resolved
|
||||
// against the store NOW so the live working set carries real bytes; missing
|
||||
// ids simply pin nothing (they surface as broken at checkpoint/verify time,
|
||||
// never as fabricated content).
|
||||
func (c *TopicContinuity) Foreground(db *sql.DB, topicID string, pinnedRefs []int64,
|
||||
openLoops []string, taskRef, provider string) error {
|
||||
var entries []WorkingEntry
|
||||
for _, ref := range pinnedRefs {
|
||||
var text string
|
||||
var id int64
|
||||
err := db.QueryRow("SELECT id, text FROM entries WHERE id=?", ref).Scan(&id, &text)
|
||||
if err == sql.ErrNoRows {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, WorkingEntry{ID: id, Text: text, Digest: DigestOf(text)})
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.enterLocked(topicID, "explicit_subject_shift")
|
||||
live, ok := c.live[topicID]
|
||||
if !ok {
|
||||
live = &liveTopic{openLoops: []string{}}
|
||||
c.live[topicID] = live
|
||||
}
|
||||
if pinnedRefs != nil {
|
||||
live.entries = entries
|
||||
}
|
||||
if openLoops != nil {
|
||||
live.openLoops = append([]string{}, openLoops...)
|
||||
}
|
||||
if taskRef != "" {
|
||||
live.taskRef = taskRef
|
||||
}
|
||||
if provider != "" {
|
||||
live.provider = provider
|
||||
}
|
||||
prev := c.states[topicID]
|
||||
c.states[topicID] = TopicState{State: StateHot, CheckpointRef: prev.CheckpointRef}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cool — eviction safety gate (§7.2): persist the topic's resume capsule
|
||||
// (pinned evidence pointers WITH digests, open loops, task ref) as a durable
|
||||
// checkpoint, then release residency. Evidence rows are untouched: cooling is
|
||||
// attention release, not deletion. An empty topicID cools the active topic;
|
||||
// with no foreground topic either, it errors.
|
||||
func (c *TopicContinuity) Cool(db *sql.DB, topicID, taskRef, provider string) (*Checkpoint, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
topic := topicID
|
||||
if topic == "" {
|
||||
topic = c.active
|
||||
}
|
||||
if topic == "" {
|
||||
return nil, fmt.Errorf("cool: no topic in the foreground and none named")
|
||||
}
|
||||
live, ok := c.live[topic]
|
||||
if !ok {
|
||||
live = &liveTopic{taskRef: taskRef, provider: provider}
|
||||
c.live[topic] = live
|
||||
}
|
||||
ids := make([]int64, len(live.entries))
|
||||
for i, e := range live.entries {
|
||||
ids[i] = e.ID
|
||||
}
|
||||
idJSON, _ := json.Marshal(ids)
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s", topic, string(idJSON), nowStamp())))
|
||||
checkpointID := "cp:" + hex.EncodeToString(sum[:])[:16]
|
||||
|
||||
task := taskRef
|
||||
if task == "" {
|
||||
task = live.taskRef
|
||||
}
|
||||
prov := provider
|
||||
if prov == "" {
|
||||
prov = live.provider
|
||||
}
|
||||
pinned := make([]PinnedRef, len(live.entries))
|
||||
for i, e := range live.entries {
|
||||
pinned[i] = PinnedRef{EntryID: e.ID, Digest: e.Digest, Text: e.Text}
|
||||
}
|
||||
checkpoint := &Checkpoint{
|
||||
SchemaVersion: CheckpointSchemaVersion,
|
||||
CheckpointID: checkpointID,
|
||||
TopicID: topic,
|
||||
TaskRef: task,
|
||||
OpenLoops: append([]string{}, live.openLoops...),
|
||||
PinnedRefs: pinned,
|
||||
// provider is recorded for observability only — continuity
|
||||
// reconstructs from Rune state, never provider KV (S07).
|
||||
Provider: prov,
|
||||
LastActiveAt: nowStamp(),
|
||||
State: "committed",
|
||||
}
|
||||
if err := atomicWriteJSON(filepath.Join(c.dir, CheckpointFilename(topic)), checkpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.states[topic] = TopicState{State: StateCooled, CheckpointRef: checkpointID}
|
||||
if c.hasActive && c.active == topic {
|
||||
c.active = ""
|
||||
c.hasActive = false
|
||||
live.entries = nil
|
||||
}
|
||||
return checkpoint, nil
|
||||
}
|
||||
|
||||
// loadCheckpoint loads a checkpoint file. A missing file is
|
||||
// NOT_FOUND_CONFIRMED; a corrupt file is SOURCE_CORRUPT naming the file
|
||||
// (per-file isolation, never silently skipped).
|
||||
func (c *TopicContinuity) loadCheckpoint(topicID string) (*Checkpoint, *RehydrateReceipt) {
|
||||
path := filepath.Join(c.dir, CheckpointFilename(topicID))
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, &RehydrateReceipt{
|
||||
Status: NotFoundConfirmed,
|
||||
Detail: fmt.Sprintf("no checkpoint for %s", topicID),
|
||||
WorkingSet: []WorkingEntry{},
|
||||
Broken: []BrokenRef{},
|
||||
OpenLoops: []string{},
|
||||
}
|
||||
}
|
||||
var cp Checkpoint
|
||||
if err := json.Unmarshal(data, &cp); err != nil || cp.TopicID != topicID {
|
||||
return nil, &RehydrateReceipt{
|
||||
Status: SourceCorrupt,
|
||||
Detail: fmt.Sprintf("checkpoint file %s unreadable/corrupt: failed schema check", filepath.Base(path)),
|
||||
WorkingSet: []WorkingEntry{},
|
||||
Broken: []BrokenRef{},
|
||||
OpenLoops: []string{},
|
||||
}
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// Rehydrate — verified rehydration (§7.3): load the authoritative checkpoint,
|
||||
// verify EVERY pinned pointer (entry exists + digest matches), and restore
|
||||
// the working set. Only an all-verified rehydration is FOUND_VERIFIED; missing
|
||||
// evidence is POINTER_BROKEN and tampered evidence is SOURCE_CORRUPT — visible
|
||||
// limitations, never fabricated continuity (S15).
|
||||
func (c *TopicContinuity) Rehydrate(db *sql.DB, topicID, provider string) RehydrateReceipt {
|
||||
cp, errReceipt := c.loadCheckpoint(topicID)
|
||||
if errReceipt != nil {
|
||||
errReceipt.TopicID = topicID
|
||||
return *errReceipt
|
||||
}
|
||||
|
||||
workingSet := []WorkingEntry{}
|
||||
broken := []BrokenRef{}
|
||||
for _, ref := range cp.PinnedRefs {
|
||||
var text string
|
||||
err := db.QueryRow("SELECT text FROM entries WHERE id=?", ref.EntryID).Scan(&text)
|
||||
if err == sql.ErrNoRows {
|
||||
broken = append(broken, BrokenRef{EntryID: ref.EntryID, Reason: "entry_missing"})
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
broken = append(broken, BrokenRef{EntryID: ref.EntryID, Reason: "entry_missing"})
|
||||
continue
|
||||
}
|
||||
if DigestOf(text) != ref.Digest {
|
||||
broken = append(broken, BrokenRef{EntryID: ref.EntryID, Reason: "digest_mismatch"})
|
||||
continue
|
||||
}
|
||||
workingSet = append(workingSet, WorkingEntry{ID: ref.EntryID, Text: text, Digest: ref.Digest})
|
||||
}
|
||||
|
||||
var status string
|
||||
for _, b := range broken {
|
||||
if b.Reason == "digest_mismatch" {
|
||||
status = SourceCorrupt
|
||||
break
|
||||
}
|
||||
}
|
||||
if status == "" && len(broken) > 0 {
|
||||
status = PointerBroken
|
||||
}
|
||||
if status == "" {
|
||||
status = FoundVerified
|
||||
}
|
||||
|
||||
fingerprintInput, _ := json.Marshal(func() [][]any {
|
||||
pairs := make([][]any, len(cp.PinnedRefs))
|
||||
for i, r := range cp.PinnedRefs {
|
||||
pairs[i] = []any{r.EntryID, r.Digest}
|
||||
}
|
||||
return pairs
|
||||
}())
|
||||
fpSum := sha256.Sum256(fingerprintInput)
|
||||
fingerprint := hex.EncodeToString(fpSum[:])[:16]
|
||||
|
||||
prov := provider
|
||||
if prov == "" {
|
||||
prov = cp.Provider
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.enterLocked(topicID, "rehydration")
|
||||
c.live[topicID] = &liveTopic{
|
||||
entries: workingSet,
|
||||
openLoops: append([]string{}, cp.OpenLoops...),
|
||||
taskRef: cp.TaskRef,
|
||||
provider: prov,
|
||||
}
|
||||
c.states[topicID] = TopicState{State: StateHot, CheckpointRef: cp.CheckpointID}
|
||||
c.mu.Unlock()
|
||||
|
||||
return RehydrateReceipt{
|
||||
TopicID: topicID,
|
||||
Status: status,
|
||||
CheckpointRef: cp.CheckpointID,
|
||||
Verified: len(workingSet),
|
||||
Total: len(cp.PinnedRefs),
|
||||
Broken: broken,
|
||||
WorkingSet: workingSet,
|
||||
OpenLoops: append([]string{}, cp.OpenLoops...),
|
||||
SourceDigest: "sha256:" + fingerprint,
|
||||
Provider: prov,
|
||||
}
|
||||
}
|
||||
|
||||
// WorkingSet — the ACTIVE topic's live entries; cooled topics contribute
|
||||
// nothing (S01: zero prompt tokens for the foreground topic's rivals). No
|
||||
// active topic → empty (zero-result is valid, MUST-05).
|
||||
func (c *TopicContinuity) WorkingSet() []WorkingEntry {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.hasActive {
|
||||
return []WorkingEntry{}
|
||||
}
|
||||
live, ok := c.live[c.active]
|
||||
if !ok {
|
||||
return []WorkingEntry{}
|
||||
}
|
||||
out := make([]WorkingEntry, len(live.entries))
|
||||
copy(out, live.entries)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── single-authority bridge (MUST-02) ───────────────────────────────────────
|
||||
|
||||
// ContributionBlock is the ContextBroker block shape (parity with
|
||||
// context_broker.ContributionBlock as consumed by the registered contributor).
|
||||
type ContributionBlock struct {
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
Content string `json:"content"`
|
||||
Tokens int `json:"tokens"`
|
||||
Source string `json:"source"`
|
||||
Evictable bool `json:"evictable"`
|
||||
}
|
||||
|
||||
// ContinuityBlocks materializes a working set as typed ContributionBlocks for
|
||||
// the ONE registered continuity contributor. The block is named 'memory' so it
|
||||
// lives under the ContextBroker's memory sub-budget with every other memory
|
||||
// block — the broker stays the single model-visible authority; this module
|
||||
// exposes no other injection path.
|
||||
func ContinuityBlocks(workingSet []WorkingEntry) []ContributionBlock {
|
||||
texts := make([]string, 0, len(workingSet))
|
||||
for _, e := range workingSet {
|
||||
t := strings.TrimSpace(e.Text)
|
||||
if t != "" {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
}
|
||||
if len(texts) == 0 {
|
||||
return nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, t := range texts {
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(t)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
content := strings.TrimSuffix(sb.String(), "\n")
|
||||
tokens := len([]rune(content)) / 4
|
||||
if tokens < 1 {
|
||||
tokens = 1
|
||||
}
|
||||
return []ContributionBlock{{
|
||||
Name: "memory",
|
||||
Priority: MemoryBlockPriority,
|
||||
Content: content,
|
||||
Tokens: tokens,
|
||||
Source: "memory_continuity.working_set",
|
||||
Evictable: true,
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Isolation — every test gets its own store DB and continuity dir so the
|
||||
// suite never touches a real memory store (preserve-stores constraint). ─────
|
||||
|
||||
func newTestStore(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "store", "memory.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func addEntry(t *testing.T, db *sql.DB, text, topic string) int64 {
|
||||
t.Helper()
|
||||
ts := NowISO()
|
||||
res, err := db.Exec(
|
||||
"INSERT INTO entries (text, importance, created_at, last_accessed, topic, entities, source, method, status, kind) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
text, 0.9, ts, ts, topic, "[]", "owner_message", "curator", "active", "fact")
|
||||
if err != nil {
|
||||
t.Fatalf("addEntry: %v", err)
|
||||
}
|
||||
eid, _ := res.LastInsertId()
|
||||
if _, err := db.Exec("INSERT INTO entries_fts (rowid, text) VALUES (?,?)", eid, text); err != nil {
|
||||
t.Fatalf("addEntry fts: %v", err)
|
||||
}
|
||||
return eid
|
||||
}
|
||||
|
||||
// Fixture 1 — exact-by-ID (G1, S03).
|
||||
func TestExactByIDFixture(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
original := "The API design froze the retry budget at 3 attempts, not 5."
|
||||
eid := addEntry(t, db, original, "A")
|
||||
digest := DigestOf(original)
|
||||
|
||||
verified := ReadByID(db, eid, &digest)
|
||||
if verified.Status != FoundVerified {
|
||||
t.Fatalf("want FOUND_VERIFIED, got %s (%v)", verified.Status, verified)
|
||||
}
|
||||
if verified.Text != original {
|
||||
t.Fatalf("verbatim text mismatch: %q", verified.Text)
|
||||
}
|
||||
if verified.Digest != digest {
|
||||
t.Fatalf("digest mismatch: %q", verified.Digest)
|
||||
}
|
||||
|
||||
tampered := DigestOf(original + " paraphrase drift")
|
||||
corrupt := ReadByID(db, eid, &tampered)
|
||||
if corrupt.Status != SourceCorrupt {
|
||||
t.Fatalf("want SOURCE_CORRUPT, got %s", corrupt.Status)
|
||||
}
|
||||
if corrupt.Text != "" {
|
||||
t.Fatalf("corrupt read must quarantine text, got %q", corrupt.Text)
|
||||
}
|
||||
|
||||
unverified := ReadByID(db, eid, nil)
|
||||
if unverified.Status != FoundUnverified {
|
||||
t.Fatalf("want FOUND_UNVERIFIED, got %s", unverified.Status)
|
||||
}
|
||||
if unverified.Text != original {
|
||||
t.Fatalf("unverified text mismatch: %q", unverified.Text)
|
||||
}
|
||||
|
||||
missing := ReadByID(db, 999999, nil)
|
||||
if missing.Status != NotFoundConfirmed {
|
||||
t.Fatalf("want NOT_FOUND_CONFIRMED, got %s", missing.Status)
|
||||
}
|
||||
if missing.Text != "" {
|
||||
t.Fatalf("not-found must not carry text, got %q", missing.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture 2 — A→B→A (S01/S02): cooling releases residency without deleting
|
||||
// evidence; B's working set carries ZERO A tokens; A rehydrates verified.
|
||||
func TestAToBToAFixture(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
aText := "Topic A decision: the cooling checkpoint owns open loops verbatim."
|
||||
bText := "Topic B decision: provider budget reserves 2048 output tokens."
|
||||
aID := addEntry(t, db, aText, "A")
|
||||
bID := addEntry(t, db, bText, "B")
|
||||
c := NewTopicContinuity(t.TempDir())
|
||||
|
||||
c.Foreground(db, "topic:A", []int64{aID}, []string{"verify retry budget"}, "", "")
|
||||
cp, err := c.Cool(db, "topic:A", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(cp.CheckpointID, "cp:") {
|
||||
t.Fatalf("checkpoint id shape: %q", cp.CheckpointID)
|
||||
}
|
||||
if got := c.TopicState("topic:A").State; got != StateCooled {
|
||||
t.Fatalf("want COOLED, got %s", got)
|
||||
}
|
||||
|
||||
// Cooling released residency — the evidence rows are untouched (G2).
|
||||
var text, status string
|
||||
if err := db.QueryRow("SELECT text, status FROM entries WHERE id=?", aID).Scan(&text, &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text != aText || status != "active" {
|
||||
t.Fatalf("evidence mutated by cooling: %q/%s", text, status)
|
||||
}
|
||||
|
||||
c.Foreground(db, "topic:B", []int64{bID}, nil, "", "")
|
||||
wsB := c.WorkingSet()
|
||||
if len(wsB) != 1 || wsB[0].ID != bID {
|
||||
t.Fatalf("B working set wrong: %+v", wsB)
|
||||
}
|
||||
renderedB := renderWorkingSet(wsB)
|
||||
if !strings.Contains(renderedB, bText[:40]) {
|
||||
t.Fatalf("B context missing its own entry: %q", renderedB)
|
||||
}
|
||||
if strings.Contains(renderedB, aText[:40]) {
|
||||
t.Fatal("S01 violated: A tokens leaked into B's context")
|
||||
}
|
||||
blocks := ContinuityBlocks(c.WorkingSet())
|
||||
if len(blocks) != 1 || blocks[0].Name != "memory" {
|
||||
t.Fatalf("working set must surface as a single memory block, got %+v", blocks)
|
||||
}
|
||||
|
||||
// S02: return to A rehydrates from the checkpoint, verified pointers,
|
||||
// without inserting unrelated B history.
|
||||
receipt := c.Rehydrate(db, "topic:A", "")
|
||||
if receipt.Status != FoundVerified {
|
||||
t.Fatalf("want FOUND_VERIFIED, got %s (%+v)", receipt.Status, receipt)
|
||||
}
|
||||
if receipt.CheckpointRef != cp.CheckpointID {
|
||||
t.Fatalf("checkpoint ref mismatch: %q vs %q", receipt.CheckpointRef, cp.CheckpointID)
|
||||
}
|
||||
if receipt.Verified != 1 || receipt.Total != 1 {
|
||||
t.Fatalf("verified/total: %d/%d", receipt.Verified, receipt.Total)
|
||||
}
|
||||
if len(receipt.WorkingSet) != 1 || receipt.WorkingSet[0].ID != aID {
|
||||
t.Fatalf("A working set wrong: %+v", receipt.WorkingSet)
|
||||
}
|
||||
|
||||
wsA := c.WorkingSet()
|
||||
renderedA := renderWorkingSet(wsA)
|
||||
if !strings.Contains(renderedA, aText[:40]) {
|
||||
t.Fatalf("A context missing its own entry: %q", renderedA)
|
||||
}
|
||||
if strings.Contains(renderedA, bText[:40]) {
|
||||
t.Fatal("anti-bloat violated: B bleed into A")
|
||||
}
|
||||
|
||||
// The transition trail records the bounded A→B→A path (§13.3.7).
|
||||
trail := c.Transitions()
|
||||
pairs := map[string]bool{}
|
||||
for _, tr := range trail {
|
||||
pairs[tr.From+"->"+tr.To] = true
|
||||
}
|
||||
if !pairs["topic:A->topic:B"] || !pairs["topic:B->topic:A"] {
|
||||
t.Fatalf("trail missing A→B→A edges: %+v", trail)
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture 3 — provider-swap (S07, G3): continuity survives a model swap;
|
||||
// rehydration reconstructs from Rune state, not provider KV.
|
||||
func TestProviderSwapFixture(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
aText := "Mid-task fact: the ledger digest gate is pinned, never auto-fed."
|
||||
aID := addEntry(t, db, aText, "A")
|
||||
dir := t.TempDir()
|
||||
c := NewTopicContinuity(dir)
|
||||
c.Foreground(db, "topic:A", []int64{aID}, []string{"commit the gate"}, "", "")
|
||||
cp, err := c.Cool(db, "topic:A", "task:207", "ollama:qwen2.5:7b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cp.Provider != "ollama:qwen2.5:7b" {
|
||||
t.Fatalf("provider not recorded: %q", cp.Provider)
|
||||
}
|
||||
|
||||
before := c.Rehydrate(db, "topic:A", "")
|
||||
if before.Status != FoundVerified {
|
||||
t.Fatalf("before swap: %s", before.Status)
|
||||
}
|
||||
|
||||
// A NEW registry instance (process restart shape) rehydrates under a
|
||||
// different provider from durable Rune state alone.
|
||||
c2 := NewTopicContinuity(dir)
|
||||
c2.Foreground(db, "topic:A", []int64{aID}, nil, "", "openai:gpt-5")
|
||||
after := c2.Rehydrate(db, "topic:A", "openai:gpt-5")
|
||||
if after.CheckpointRef != before.CheckpointRef {
|
||||
t.Fatalf("checkpoint ref drift: %q vs %q", after.CheckpointRef, before.CheckpointRef)
|
||||
}
|
||||
if after.SourceDigest != before.SourceDigest {
|
||||
t.Fatalf("source digest drift: %q vs %q", after.SourceDigest, before.SourceDigest)
|
||||
}
|
||||
if len(after.WorkingSet) != 1 || after.WorkingSet[0].Text != aText {
|
||||
t.Fatalf("working set wrong: %+v", after.WorkingSet)
|
||||
}
|
||||
if after.Verified != before.Verified || after.Status != FoundVerified {
|
||||
t.Fatalf("after swap: %s verified=%d", after.Status, after.Verified)
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture 4 — cache-purge (S09, MUST-18): deleting all disposable derived
|
||||
// caches is slower, never amnesiac.
|
||||
func TestCachePurgeFixture(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
aText := "Cache purge must not amnesiac: this row survives vec/fts wipe."
|
||||
aID := addEntry(t, db, aText, "A")
|
||||
c := NewTopicContinuity(t.TempDir())
|
||||
c.Foreground(db, "topic:A", []int64{aID}, nil, "", "")
|
||||
c.Cool(db, "topic:A", "task:209", "")
|
||||
|
||||
purge, err := PurgeDisposableCaches(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if purge.Status != "purged" {
|
||||
t.Fatalf("purge status: %s", purge.Status)
|
||||
}
|
||||
if purge.Purged.EntriesVec < 0 {
|
||||
t.Fatalf("purged count: %d", purge.Purged.EntriesVec)
|
||||
}
|
||||
|
||||
var text, status string
|
||||
if err := db.QueryRow("SELECT text, status FROM entries WHERE id=?", aID).Scan(&text, &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text != aText || status != "active" {
|
||||
t.Fatalf("source row did not survive purge: %q/%s", text, status)
|
||||
}
|
||||
|
||||
digest := DigestOf(aText)
|
||||
read := ReadByID(db, aID, &digest)
|
||||
if read.Status != FoundVerified {
|
||||
t.Fatalf("exact read after purge: %s", read.Status)
|
||||
}
|
||||
|
||||
receipt := c.Rehydrate(db, "topic:A", "")
|
||||
if receipt.Status != FoundVerified {
|
||||
t.Fatalf("rehydration after purge: %s", receipt.Status)
|
||||
}
|
||||
|
||||
ms := MemoryStatus(db)
|
||||
if ms.Status != "degraded" {
|
||||
t.Fatalf("honest degradation expected, got %s (%+v)", ms.Status, ms)
|
||||
}
|
||||
if _, ok := ms.Lanes["lexical"]; !ok {
|
||||
t.Fatal("lexical lane missing from status")
|
||||
}
|
||||
if _, ok := ms.Lanes["dense"]; !ok {
|
||||
t.Fatal("dense lane missing from status")
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture 5 — correction (S06, G4): supersede without deleting history.
|
||||
func TestCorrectionFixture(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
oldText := "The retry budget is 5 attempts."
|
||||
newText := "The retry budget is 3 attempts."
|
||||
eid := addEntry(t, db, oldText, "A")
|
||||
|
||||
result, err := RecordCorrection(db, eid, newText, "owner-correction")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "superseded" {
|
||||
t.Fatalf("correction status: %s (%+v)", result.Status, result)
|
||||
}
|
||||
if result.OldID != eid || result.NewID == eid {
|
||||
t.Fatalf("ids: old=%d new=%d", result.OldID, result.NewID)
|
||||
}
|
||||
|
||||
hist, err := ReadHistory(db, eid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hist.Entry.Text != oldText {
|
||||
t.Fatalf("history must stay verbatim: %q", hist.Entry.Text)
|
||||
}
|
||||
if hist.Entry.Status != "superseded" {
|
||||
t.Fatalf("history status: %s", hist.Entry.Status)
|
||||
}
|
||||
if len(hist.Corrections) != 1 || hist.Corrections[0].SupersededBy != result.NewID {
|
||||
t.Fatalf("correction lineage: %+v", hist.Corrections)
|
||||
}
|
||||
|
||||
// Current truth: the lexical index surfaces the correction, never the
|
||||
// stale fact (shared prefix — assert on the distinguishing tails).
|
||||
rows, err := RecallLexical(db, "retry budget attempts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foundNew, foundOld := false, false
|
||||
for _, txt := range rows {
|
||||
if strings.Contains(txt, "3 attempts") {
|
||||
foundNew = true
|
||||
}
|
||||
if strings.Contains(txt, "5 attempts") {
|
||||
foundOld = true
|
||||
}
|
||||
}
|
||||
if !foundNew {
|
||||
t.Fatalf("correction not surfaced by lexical index: %v", rows)
|
||||
}
|
||||
if foundOld {
|
||||
t.Fatalf("stale fact resurrected by lexical index: %v", rows)
|
||||
}
|
||||
|
||||
// The corrected row is itself exact-readable (G1 on the new id).
|
||||
digest := DigestOf(newText)
|
||||
read := ReadByID(db, result.NewID, &digest)
|
||||
if read.Status != FoundVerified {
|
||||
t.Fatalf("corrected row read: %s", read.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture 6 — pointer-liveness (S15, G5).
|
||||
func TestPointerLivenessFixture(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
t1 := "Pointer one: the ghost anchor keeps the topic resume ref."
|
||||
t2 := "Pointer two: the checkpoint digest pins these exact bytes."
|
||||
e1 := addEntry(t, db, t1, "A")
|
||||
e2 := addEntry(t, db, t2, "A")
|
||||
c := NewTopicContinuity(t.TempDir())
|
||||
c.Foreground(db, "topic:A", []int64{e1, e2}, nil, "", "")
|
||||
c.Cool(db, "topic:A", "task:211", "")
|
||||
|
||||
// Missing evidence (authorized deletion elsewhere) → broken pointer.
|
||||
forgetResult, err := Forget(db, e1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if forgetResult.Status != "forgotten" {
|
||||
t.Fatalf("forget: %+v", forgetResult)
|
||||
}
|
||||
receipt := c.Rehydrate(db, "topic:A", "")
|
||||
if receipt.Status != PointerBroken {
|
||||
t.Fatalf("want POINTER_BROKEN, got %s (%+v)", receipt.Status, receipt)
|
||||
}
|
||||
if receipt.Verified != 1 || receipt.Total != 2 {
|
||||
t.Fatalf("verified/total: %d/%d", receipt.Verified, receipt.Total)
|
||||
}
|
||||
brokenIDs := map[int64]bool{}
|
||||
for _, b := range receipt.Broken {
|
||||
brokenIDs[b.EntryID] = true
|
||||
}
|
||||
if !brokenIDs[e1] {
|
||||
t.Fatalf("broken list missing e1: %+v", receipt.Broken)
|
||||
}
|
||||
if strings.Contains(renderWorkingSet(receipt.WorkingSet), t1[:30]) {
|
||||
t.Fatal("no fabrication allowed: missing entry text leaked")
|
||||
}
|
||||
|
||||
// Tampered evidence (digest mismatch) → typed corruption quarantine.
|
||||
c3 := NewTopicContinuity(t.TempDir())
|
||||
e3 := addEntry(t, db, t1, "A2")
|
||||
e4 := addEntry(t, db, t2, "A2")
|
||||
c3.Foreground(db, "topic:A2", []int64{e3, e4}, nil, "", "")
|
||||
c3.Cool(db, "topic:A2", "task:212", "")
|
||||
UpdateEntryText(db, e4, t2+" drifted") // post-checkpoint mutation
|
||||
receipt2 := c3.Rehydrate(db, "topic:A2", "")
|
||||
if receipt2.Status != SourceCorrupt {
|
||||
t.Fatalf("want SOURCE_CORRUPT, got %s (%+v)", receipt2.Status, receipt2)
|
||||
}
|
||||
reasons := map[int64]string{}
|
||||
for _, b := range receipt2.Broken {
|
||||
reasons[b.EntryID] = b.Reason
|
||||
}
|
||||
if reasons[e4] != "digest_mismatch" {
|
||||
t.Fatalf("e4 reason: %+v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
// Cooling never deletes evidence — recall still finds the cooled row.
|
||||
func TestCoolingNeverDeletesEvidence(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
text := "Cooling changes residency, never the durable record."
|
||||
eid := addEntry(t, db, text, "A")
|
||||
c := NewTopicContinuity(t.TempDir())
|
||||
c.Foreground(db, "topic:A", []int64{eid}, nil, "", "")
|
||||
c.Cool(db, "topic:A", "task:213", "")
|
||||
|
||||
var gotText, gotStatus string
|
||||
if err := db.QueryRow("SELECT text, status FROM entries WHERE id=?", eid).Scan(&gotText, &gotStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotText != text || gotStatus != "active" {
|
||||
t.Fatalf("cooling mutated evidence: %q/%s", gotText, gotStatus)
|
||||
}
|
||||
rows, err := RecallLexical(db, "durable record residency")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range rows {
|
||||
if strings.Contains(r, "durable record") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("cooled evidence no longer addressable: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// Right-to-forget wipes scope + audits; repeated wipes are typed NOT_FOUND.
|
||||
func TestRightToForgetWipesScopeAndAudits(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
text := "Forget me: authorized deletion wipes every derived copy in scope."
|
||||
eid := addEntry(t, db, text, "A")
|
||||
if _, err := db.Exec("INSERT INTO entries_vec (rowid, embedding) VALUES (?,?)", eid, "[0.1,0.2]"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := addEntry(t, db, "Keep me: unrelated row must survive the wipe.", "B")
|
||||
ts := NowISO()
|
||||
if _, err := db.Exec("INSERT INTO associations (src_id, dst_id, strength, updated_at) VALUES (?,?,?,?)", eid, other, 0.5, ts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := db.Exec("INSERT INTO scenes (name, summary, member_ids, created_at) VALUES (?,?,?,?)", "scene-1", "", fmt.Sprintf("[%d,%d]", eid, other), ts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sceneID, _ := res.LastInsertId()
|
||||
|
||||
result, err := Forget(db, eid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "forgotten" {
|
||||
t.Fatalf("forget status: %+v", result)
|
||||
}
|
||||
if result.AuditID < 1 {
|
||||
t.Fatalf("audit id: %d", result.AuditID)
|
||||
}
|
||||
scope := map[string]bool{}
|
||||
for _, s := range result.Scope {
|
||||
scope[s] = true
|
||||
}
|
||||
for _, surface := range []string{"entries", "entries_vec", "entries_fts", "associations", "scenes"} {
|
||||
if !scope[surface] {
|
||||
t.Fatalf("scope missing %s: %v", surface, result.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM entries WHERE id=?", eid).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("entries row survived: n=%d err=%v", n, err)
|
||||
}
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM entries_vec WHERE rowid=?", eid).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("vec row survived: n=%d err=%v", n, err)
|
||||
}
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM associations WHERE src_id=? OR dst_id=?", eid, eid).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("association survived: n=%d err=%v", n, err)
|
||||
}
|
||||
var membersJSON string
|
||||
if err := db.QueryRow("SELECT member_ids FROM scenes WHERE id=?", sceneID).Scan(&membersJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var members []int64
|
||||
if err := json.Unmarshal([]byte(membersJSON), &members); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hasEid, hasOther := false, false
|
||||
for _, m := range members {
|
||||
if m == eid {
|
||||
hasEid = true
|
||||
}
|
||||
if m == other {
|
||||
hasOther = true
|
||||
}
|
||||
}
|
||||
if hasEid || !hasOther {
|
||||
t.Fatalf("scene membership wrong after wipe: %v", members)
|
||||
}
|
||||
|
||||
// Retrieval never resurrects it; exact read is typed not-found.
|
||||
rowsW, errW := RecallLexical(db, "authorized deletion derived copy")
|
||||
if errW != nil {
|
||||
t.Fatal(errW)
|
||||
}
|
||||
for _, r := range rowsW {
|
||||
if strings.Contains(r, "Forget me") {
|
||||
t.Fatalf("wiped content resurrected: %q", r)
|
||||
}
|
||||
}
|
||||
digest := DigestOf(text)
|
||||
if got := ReadByID(db, eid, &digest).Status; got != NotFoundConfirmed {
|
||||
t.Fatalf("want NOT_FOUND_CONFIRMED, got %s", got)
|
||||
}
|
||||
|
||||
// The audit trail records the authorized wipe.
|
||||
var claim string
|
||||
if err := db.QueryRow("SELECT claim FROM audit_log WHERE id=?", result.AuditID).Scan(&claim); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(claim, "forget") {
|
||||
t.Fatalf("audit claim: %q", claim)
|
||||
}
|
||||
|
||||
// Unrelated rows survive.
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM entries WHERE id=?", other).Scan(&n); err != nil || n != 1 {
|
||||
t.Fatalf("unrelated row lost: n=%d err=%v", n, err)
|
||||
}
|
||||
|
||||
// Idempotent + honest on a second wipe: typed not-found, no scope claims.
|
||||
again, err := Forget(db, eid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Status != NotFoundConfirmed {
|
||||
t.Fatalf("second wipe status: %+v", again)
|
||||
}
|
||||
if len(again.Scope) != 0 {
|
||||
t.Fatalf("second wipe must claim no scope: %v", again.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
// Honest degraded states: unknown topic, corrupt checkpoint, dead store.
|
||||
func TestDegradedStatesAreHonest(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
// Unknown topic: confirmed not-found, never a fabricated resume.
|
||||
c := NewTopicContinuity(t.TempDir())
|
||||
missing := c.Rehydrate(db, "topic:ghost", "")
|
||||
if missing.Status != NotFoundConfirmed {
|
||||
t.Fatalf("want NOT_FOUND_CONFIRMED, got %s", missing.Status)
|
||||
}
|
||||
if len(missing.WorkingSet) != 0 {
|
||||
t.Fatalf("fabricated working set: %+v", missing.WorkingSet)
|
||||
}
|
||||
|
||||
// Corrupt checkpoint file: typed corruption naming the file, isolated.
|
||||
eid := addEntry(t, db, "Corrupt checkpoint isolation fixture row.", "A")
|
||||
dir := t.TempDir()
|
||||
c2 := NewTopicContinuity(dir)
|
||||
c2.Foreground(db, "topic:A", []int64{eid}, nil, "", "")
|
||||
c2.Cool(db, "topic:A", "task:214", "")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("no checkpoint file: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, entries[0].Name())
|
||||
if err := os.WriteFile(target, []byte("{ not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corrupt := NewTopicContinuity(dir).Rehydrate(db, "topic:A", "")
|
||||
if corrupt.Status != SourceCorrupt {
|
||||
t.Fatalf("want SOURCE_CORRUPT, got %s (%+v)", corrupt.Status, corrupt)
|
||||
}
|
||||
if corrupt.Detail == "" || !strings.Contains(corrupt.Detail, entries[0].Name()) {
|
||||
t.Fatalf("corruption must name the file: %q", corrupt.Detail)
|
||||
}
|
||||
|
||||
// Store unavailable: closed connection → typed unavailable, not "ready".
|
||||
db.Close()
|
||||
ms := MemoryStatus(db)
|
||||
if ms.Status != "unavailable" {
|
||||
t.Fatalf("want unavailable, got %s", ms.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStatusReadyOnHealthyStore(t *testing.T) {
|
||||
db := newTestStore(t)
|
||||
eid := addEntry(t, db, "Healthy store fixture: active entry with live indexes.", "A")
|
||||
if _, err := db.Exec("INSERT INTO entries_vec (rowid, embedding) VALUES (?,?)", eid, "[0.1,0.2]"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ms := MemoryStatus(db)
|
||||
if ms.Status != "ready" {
|
||||
t.Fatalf("want ready, got %s (%+v)", ms.Status, ms)
|
||||
}
|
||||
if ms.Lanes["store"] != "ok" {
|
||||
t.Fatalf("store lane: %q", ms.Lanes["store"])
|
||||
}
|
||||
}
|
||||
|
||||
func renderWorkingSet(ws []WorkingEntry) string {
|
||||
var sb strings.Builder
|
||||
for _, e := range ws {
|
||||
sb.WriteString(e.Text)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// TestDigestOfFullLengthPinsCollisionResistance — mutation-proven gap fill:
|
||||
// a truncated digest still passes equality-based parity tests, so the
|
||||
// full 64-hex length + prefix must be pinned explicitly.
|
||||
func TestDigestOfFullLengthPinsCollisionResistance(t *testing.T) {
|
||||
d := DigestOf("mutation probe")
|
||||
if !strings.HasPrefix(d, "sha256:") {
|
||||
t.Fatalf("digest prefix missing: %q", d)
|
||||
}
|
||||
hexPart := strings.TrimPrefix(d, "sha256:")
|
||||
if len(hexPart) != 64 {
|
||||
t.Fatalf("digest truncated: got %d hex chars, want 64", len(hexPart))
|
||||
}
|
||||
if _, err := hex.DecodeString(hexPart); err != nil {
|
||||
t.Fatalf("digest not hex: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
module rune/memory-continuity
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require modernc.org/sqlite v1.49.1
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,65 @@
|
||||
// main — headless ops entry point (context-broker precedent): expose the
|
||||
// typed read and honest memory status over a store path for scripts.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: memory-continuity <status|read|serve> <db-or-addr> [entry-id] [digest-or-dir]")
|
||||
os.Exit(2)
|
||||
}
|
||||
cmd, dbPath := os.Args[1], os.Args[2]
|
||||
db, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "open store: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
if cmd == "serve" {
|
||||
// serve <addr> <db> [continuity-dir] — the read-only /v1/memory/*
|
||||
// surface for the TUI's memory/continuity feeds.
|
||||
addr := os.Args[2]
|
||||
dir := ".continuity"
|
||||
if len(os.Args) > 4 {
|
||||
dir = os.Args[4]
|
||||
}
|
||||
srv := NewServer(db, NewTopicContinuity(dir))
|
||||
fmt.Fprintf(os.Stderr, "memory-continuity reading on http://%s\n", addr)
|
||||
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch cmd {
|
||||
case "status":
|
||||
ms := MemoryStatus(db)
|
||||
out, _ := marshalIndent(ms)
|
||||
fmt.Println(string(out))
|
||||
case "read":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Fprintln(os.Stderr, "usage: memory-continuity read <db> <entry-id> [expected-digest]")
|
||||
os.Exit(2)
|
||||
}
|
||||
var id int64
|
||||
if _, err := fmt.Sscanf(os.Args[3], "%d", &id); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "bad entry id: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
var digest *string
|
||||
if len(os.Args) > 4 {
|
||||
digest = &os.Args[4]
|
||||
}
|
||||
res := ReadByID(db, id, digest)
|
||||
out, _ := marshalIndent(res)
|
||||
fmt.Println(string(out))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
// server.go — the read-only HTTP surface over the continuity store and
|
||||
// topic registry (rune-core internal/runecore server.go precedent: same
|
||||
// error envelope {"error":{"code","message"}}, honest typed statuses).
|
||||
// READ-ONLY for this wave: writes stay on the Python oracle
|
||||
// (src/rune/memory_continuity.py) until parity soak — no POST/PUT/DELETE
|
||||
// route exists here by construction, and the handler method gate makes
|
||||
// one a 405 rather than an accident.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Server is the memory-continuity read surface over one open store and
|
||||
// one topic continuity registry.
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
tc *TopicContinuity
|
||||
|
||||
started string
|
||||
identity string
|
||||
}
|
||||
|
||||
// NewServer builds the surface.
|
||||
func NewServer(db *sql.DB, tc *TopicContinuity) *Server {
|
||||
return &Server{db: db, tc: tc, identity: "memory-continuity", started: NowISO()}
|
||||
}
|
||||
|
||||
// Handler mounts every route.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
read := func(path string, h http.HandlerFunc) {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != path {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
errorEnvelope(w, http.StatusMethodNotAllowed, "read_only",
|
||||
"memory-continuity serves reads only this wave; writes stay on the Python oracle")
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
read("/v1/memory/status", s.handleStatus)
|
||||
read("/v1/memory/read", s.handleRead)
|
||||
read("/v1/memory/continuity", s.handleContinuity)
|
||||
read("/v1/memory/checkpoint", s.handleCheckpoint)
|
||||
read("/v1/health", s.handleHealth)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// errorEnvelope mirrors runecore's error envelope exactly.
|
||||
func errorEnvelope(w http.ResponseWriter, status int, code, message string) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"error": map[string]string{"code": code, "message": message},
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
// handleStatus: honest memory status (ready/degraded/unavailable with
|
||||
// per-lane detail) — the same typed result the CLI prints.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, MemoryStatus(s.db))
|
||||
}
|
||||
|
||||
// handleRead: exact-by-ID verbatim read with optional expected digest.
|
||||
// - /v1/memory/read?id=42
|
||||
// - /v1/memory/read?id=42&digest=sha256:...
|
||||
//
|
||||
// A missing/invalid id is a 400 envelope; the typed statuses
|
||||
// (FOUND_VERIFIED / FOUND_UNVERIFIED / SOURCE_CORRUPT /
|
||||
// NOT_FOUND_CONFIRMED) ride a 200 so the TUI can render them directly.
|
||||
func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", "missing id parameter")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", "invalid id: "+idStr)
|
||||
return
|
||||
}
|
||||
var digest *string
|
||||
if d := r.URL.Query().Get("digest"); d != "" {
|
||||
digest = &d
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ReadByID(s.db, id, digest))
|
||||
}
|
||||
|
||||
// handleContinuity: the topic-continuity block feed — the active topic
|
||||
// (if any), its live working set materialized as typed contribution
|
||||
// blocks (the ContextBroker block shape the TUI's memory pane consumes),
|
||||
// the bounded transition trail, and every known topic's attention state.
|
||||
func (s *Server) handleContinuity(w http.ResponseWriter, r *http.Request) {
|
||||
active, hasActive := s.tc.ActiveTopic()
|
||||
working := s.tc.WorkingSet()
|
||||
blocks := ContinuityBlocks(working)
|
||||
if blocks == nil {
|
||||
blocks = []ContributionBlock{}
|
||||
}
|
||||
states := s.tc.AllTopicStates()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"identity": s.identity,
|
||||
"active_topic": active,
|
||||
"has_active": hasActive,
|
||||
"working_set": working,
|
||||
"blocks": blocks,
|
||||
"transitions": s.tc.Transitions(),
|
||||
"topic_states": states,
|
||||
"checkpoint_dir": s.tc.Dir(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleCheckpoint: a read-only checkpoint peek for one topic
|
||||
// (?topic=...) — the durable resume capsule verbatim, without touching
|
||||
// the live registry (Rehydrate is a state transition, not a read).
|
||||
// Unknown topics answer the typed NOT_FOUND_CONFIRMED receipt; the
|
||||
// route never fabricates continuity.
|
||||
func (s *Server) handleCheckpoint(w http.ResponseWriter, r *http.Request) {
|
||||
topic := strings.TrimSpace(r.URL.Query().Get("topic"))
|
||||
if topic == "" {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", "missing topic parameter")
|
||||
return
|
||||
}
|
||||
cp, receipt := s.tc.PeekCheckpoint(topic)
|
||||
if receipt != nil {
|
||||
writeJSON(w, http.StatusOK, receipt)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cp)
|
||||
}
|
||||
|
||||
// handleHealth: liveness with identity + start stamp.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"identity": s.identity,
|
||||
"status": "ok",
|
||||
"started": s.started,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "mem.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO entries (text, created_at, status) VALUES ('owner preferstyped statuses', '2026-10-21T00:00:00+00:00', 'active')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
tc := NewTopicContinuity(dir)
|
||||
ts := httptest.NewServer(NewServer(db, tc).Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
func TestMemoryStatusEndpoint(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
code, body := get(t, ts.URL+"/v1/memory/status")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status: got %d", code)
|
||||
}
|
||||
if body["status"] != "ready" && body["status"] != "degraded" {
|
||||
t.Fatalf("honest overall status, got %v", body["status"])
|
||||
}
|
||||
if body["active_entries"].(float64) != 1 {
|
||||
t.Fatalf("active_entries, got %v", body["active_entries"])
|
||||
}
|
||||
lanes, ok := body["lanes"].(map[string]any)
|
||||
if !ok || lanes["store"] != "ok" {
|
||||
t.Fatalf("lanes detail missing: %v", body["lanes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadByIDEndpoint(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
code, body := get(t, ts.URL+"/v1/memory/read?id=1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("read: got %d", code)
|
||||
}
|
||||
if body["status"] != FoundUnverified {
|
||||
t.Fatalf("no digest on record → FOUND_UNVERIFIED, got %v", body["status"])
|
||||
}
|
||||
if body["text"] == "" {
|
||||
t.Fatal("verbatim text missing")
|
||||
}
|
||||
// not found is typed, not a 404 envelope
|
||||
_, body = get(t, ts.URL+"/v1/memory/read?id=999")
|
||||
if body["status"] != NotFoundConfirmed {
|
||||
t.Fatalf("NOT_FOUND_CONFIRMED, got %v", body["status"])
|
||||
}
|
||||
// bad id is a 400 envelope
|
||||
code, body = get(t, ts.URL+"/v1/memory/read?id=abc")
|
||||
if code != http.StatusBadRequest || body["error"] == nil {
|
||||
t.Fatalf("bad id: got %d %v", code, body)
|
||||
}
|
||||
// missing id is a 400 envelope
|
||||
code, body = get(t, ts.URL+"/v1/memory/read")
|
||||
if code != http.StatusBadRequest || body["error"] == nil {
|
||||
t.Fatalf("missing id: got %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadByIDVerifiedWithDigest(t *testing.T) {
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "mem.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO entries (text, created_at, status) VALUES ('pinned evidence', '2026-10-21T00:00:00+00:00', 'active')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := DigestOf("pinned evidence")
|
||||
ts := httptest.NewServer(NewServer(db, NewTopicContinuity(t.TempDir())).Handler())
|
||||
defer ts.Close()
|
||||
_, body := get(t, ts.URL+"/v1/memory/read?id=1&digest="+digest)
|
||||
if body["status"] != FoundVerified {
|
||||
t.Fatalf("matching digest → FOUND_VERIFIED, got %v", body["status"])
|
||||
}
|
||||
_, body = get(t, ts.URL+"/v1/memory/read?id=1&digest=sha256:deadbeef")
|
||||
if body["status"] != SourceCorrupt {
|
||||
t.Fatalf("mismatched digest → SOURCE_CORRUPT, got %v", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuityBlocksEndpoint(t *testing.T) {
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "mem.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO entries (text, created_at, status) VALUES ('deploy checklist step one', '2026-10-21T00:00:00+00:00', 'active')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc := NewTopicContinuity(t.TempDir())
|
||||
if err := tc.Foreground(db, "topic:deploy", []int64{1}, []string{"await signoff"}, "task-7", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(NewServer(db, tc).Handler())
|
||||
defer ts.Close()
|
||||
code, body := get(t, ts.URL+"/v1/memory/continuity")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("continuity: got %d", code)
|
||||
}
|
||||
if body["active_topic"] != "topic:deploy" || body["has_active"] != true {
|
||||
t.Fatalf("active topic: %v", body)
|
||||
}
|
||||
ws, ok := body["working_set"].([]any)
|
||||
if !ok || len(ws) != 1 {
|
||||
t.Fatalf("working set: %v", body["working_set"])
|
||||
}
|
||||
blocks, ok := body["blocks"].([]any)
|
||||
if !ok || len(blocks) != 1 {
|
||||
t.Fatalf("continuity blocks: %v", body["blocks"])
|
||||
}
|
||||
blk := blocks[0].(map[string]any)
|
||||
if blk["name"] != "memory" || blk["priority"].(float64) != MemoryBlockPriority {
|
||||
t.Fatalf("block shape: %v", blk)
|
||||
}
|
||||
states := body["topic_states"].(map[string]any)
|
||||
if states["topic:deploy"].(map[string]any)["state"] != StateHot {
|
||||
t.Fatalf("topic state: %v", states)
|
||||
}
|
||||
// read-only: a GET must not have changed the store
|
||||
var n int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM audit_log").Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("read surface wrote to the store: audit_log=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointEndpointReadOnly(t *testing.T) {
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "mem.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tc := NewTopicContinuity(t.TempDir())
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO entries (text, created_at, status) VALUES ('evidence A', '2026-10-21T00:00:00+00:00', 'active')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tc.Foreground(db, "topic:a", []int64{1}, nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tc.Cool(db, "topic:a", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(NewServer(db, tc).Handler())
|
||||
defer ts.Close()
|
||||
// after cool, the registry has no active topic — a fresh server sees it via peek
|
||||
code, body := get(t, ts.URL+"/v1/memory/checkpoint?topic=topic:a")
|
||||
if code != http.StatusOK || body["topic_id"] != "topic:a" || body["state"] != "committed" {
|
||||
t.Fatalf("checkpoint peek: %d %v", code, body)
|
||||
}
|
||||
// unknown topic → typed receipt, never fabricated
|
||||
_, body = get(t, ts.URL+"/v1/memory/checkpoint?topic=topic:zz")
|
||||
if body["status"] != NotFoundConfirmed {
|
||||
t.Fatalf("unknown topic: %v", body)
|
||||
}
|
||||
// missing topic → 400 envelope
|
||||
code, body = get(t, ts.URL+"/v1/memory/checkpoint")
|
||||
if code != http.StatusBadRequest || body["error"] == nil {
|
||||
t.Fatalf("missing topic: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyGate(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||
req, _ := http.NewRequest(method, ts.URL+"/v1/memory/status", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("%s on the read surface must be 405, got %d", method, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
// no route is the runecore envelope
|
||||
code, body := get(t, ts.URL+"/v1/memory/write")
|
||||
if code != http.StatusNotFound || body["error"] == nil {
|
||||
t.Fatalf("unknown route: %d %v", code, body)
|
||||
}
|
||||
if fmt.Sprint(body["error"].(map[string]any)["code"]) != "not_found" {
|
||||
t.Fatalf("envelope code: %v", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package main — memory-continuity: the Go port of src/rune/memory_continuity.py
|
||||
// (retirement-map row 25, memory-context-continuity family; Oracle-Battery
|
||||
// Parity Pattern like src/go/context-broker).
|
||||
//
|
||||
// Cooling releases attention WITHOUT deleting evidence; rehydration verifies
|
||||
// every source pointer before claiming continuity; recall/status results are
|
||||
// typed and honest; the right to forget is an authorized, audited wipe.
|
||||
// The Python module remains the authoritative seam until an integrated soak
|
||||
// (delegation facade pattern); this port carries IO behind a store seam
|
||||
// (database/sql over the canonical SQLite schema) so the parity battery runs
|
||||
// hermetically.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// NowISO — parity with memory_store.now_iso (second precision, UTC).
|
||||
func NowISO() string {
|
||||
return time.Now().UTC().Format("2006-01-02T15:04:05-07:00")
|
||||
}
|
||||
|
||||
// OpenStore opens (and creates) the canonical hybrid memory store schema.
|
||||
// The Go seam mirrors memory_store.connect: same tables, FTS5 contentless
|
||||
// lexical index. The dense lane (entries_vec) is surfaced as a plain rowid
|
||||
// table here — the vec0 virtual table itself stays a Python-side writer
|
||||
// concern; the continuity runtime only counts and deletes its rows.
|
||||
func OpenStore(dbPath string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schema := `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
|
||||
text, content='', tokenize='porter unicode61'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entries_vec (
|
||||
rowid INTEGER PRIMARY KEY,
|
||||
embedding TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
importance REAL DEFAULT 0.5,
|
||||
created_at TEXT NOT NULL,
|
||||
last_accessed TEXT,
|
||||
topic TEXT DEFAULT '',
|
||||
entities TEXT DEFAULT '[]',
|
||||
source TEXT DEFAULT '',
|
||||
method TEXT DEFAULT 'curator',
|
||||
status TEXT DEFAULT 'active',
|
||||
valid_from TEXT,
|
||||
valid_until TEXT,
|
||||
kind TEXT DEFAULT 'fact'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
claim TEXT,
|
||||
verdict TEXT,
|
||||
evidence TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS associations (
|
||||
src_id INTEGER NOT NULL,
|
||||
dst_id INTEGER NOT NULL,
|
||||
strength REAL DEFAULT 0.1,
|
||||
updated_at TEXT,
|
||||
PRIMARY KEY (src_id, dst_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS scenes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
summary TEXT DEFAULT '',
|
||||
member_ids TEXT DEFAULT '[]',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_status ON entries(status);
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// AuditAppend — parity with memory_store.audit_append: hash-chained ledger
|
||||
// (prev|claim|verdict|evidence), 24-hex digest, GENESIS seed.
|
||||
func AuditAppend(db *sql.DB, claim, verdict, evidence string) (hash string, id int64, err error) {
|
||||
prev := "GENESIS"
|
||||
row := db.QueryRow("SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1")
|
||||
if err := row.Scan(&prev); err != nil && err != sql.ErrNoRows {
|
||||
return "", 0, err
|
||||
}
|
||||
payload := fmt.Sprintf("%s|%s|%s|%s", prev, claim, verdict, evidence)
|
||||
sum := sha256.Sum256([]byte(payload))
|
||||
hash = hex.EncodeToString(sum[:])[:24]
|
||||
ts := NowISO()
|
||||
res, err := db.Exec(
|
||||
"INSERT INTO audit_log (prev_hash, hash, claim, verdict, evidence, created_at) VALUES (?,?,?,?,?,?)",
|
||||
prev, hash, claim, verdict, evidence, ts)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
id, err = res.LastInsertId()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hash, id, nil
|
||||
}
|
||||
|
||||
// FtsDelete — parity with memory_store._fts_delete: a contentless FTS5 table
|
||||
// cannot DELETE FROM; removal is the special 'delete' command carrying the
|
||||
// row's stored text.
|
||||
func FtsDelete(db *sql.DB, eid int64) {
|
||||
var text string
|
||||
err := db.QueryRow("SELECT text FROM entries WHERE id=?", eid).Scan(&text)
|
||||
if err != nil || text == "" {
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec("INSERT INTO entries_fts(entries_fts, rowid, text) VALUES('delete', ?, ?)", eid, text)
|
||||
}
|
||||
|
||||
// UpdateEntryText — parity with the store's update_entry text path (re-index,
|
||||
// no re-embed in the seam: the dense lane is not consulted by this runtime).
|
||||
func UpdateEntryText(db *sql.DB, eid int64, text string) error {
|
||||
FtsDelete(db, eid)
|
||||
if _, err := db.Exec("UPDATE entries SET text=?, last_accessed=? WHERE id=?", text, NowISO(), eid); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec("INSERT INTO entries_fts (rowid, text) VALUES (?,?)", eid, text)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecallLexical — conjunctive FTS5 (BM25) probe over active entries. The
|
||||
// parity battery uses it where the Python suite exercises ms.recall; dense
|
||||
// fusion stays in the store/selector, not this runtime.
|
||||
func RecallLexical(db *sql.DB, query string) ([]string, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
terms := strings.Fields(query)
|
||||
quoted := make([]string, len(terms))
|
||||
for i, t := range terms {
|
||||
quoted[i] = `"` + strings.ReplaceAll(t, `"`, `""`) + `"`
|
||||
}
|
||||
rows, err := db.Query(
|
||||
"SELECT e.text FROM entries_fts f JOIN entries e ON e.id = f.rowid "+
|
||||
"WHERE entries_fts MATCH ? AND e.status='active' ORDER BY bm25(entries_fts) LIMIT 20",
|
||||
strings.Join(quoted, " "))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var text string
|
||||
if err := rows.Scan(&text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
var _ = json.Marshal // keep encoding/json linked for checkpoint payloads in continuity.go
|
||||
|
||||
func marshalIndent(v any) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// cli_narrative.go — the release-time wiring for the plain-English
|
||||
// narrative. Two commands:
|
||||
//
|
||||
// narrative --since vX [--to Y] print the section (proof/review)
|
||||
// prepend-narrative --server S --repo R fetch the release for tag,
|
||||
// --tag vX --since vPrev [--to Y] prepend the section to its
|
||||
// body via the Gitea API
|
||||
//
|
||||
// Called by the auto-version workflow right after release creation, so EVERY
|
||||
// future release gets the plain-English section at the top of its body.
|
||||
// The governed note-generation path (release_history_cli.py init) is never
|
||||
// touched; prepend is idempotent (a body that already carries the section
|
||||
// header is left alone).
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// narrativeSectionHeader — the marker used for idempotent prepends.
|
||||
const narrativeSectionHeader = "# What's new — plain English"
|
||||
|
||||
// httpTransport — the production Transport: plain net/http.
|
||||
type httpTransport struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (h httpTransport) Do(url, method string, headers map[string]string, data []byte) (Response, error) {
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
client := h.client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return Response{Status: resp.StatusCode, Body: buf}, &HTTPError{Code: resp.StatusCode, Body: buf}
|
||||
}
|
||||
return Response{Status: resp.StatusCode, Body: buf}, nil
|
||||
}
|
||||
|
||||
func cmdNarrative(args []string) int {
|
||||
since, to := "", "HEAD"
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--since":
|
||||
i++
|
||||
since = arg(i, args)
|
||||
case "--to":
|
||||
i++
|
||||
to = arg(i, args)
|
||||
}
|
||||
}
|
||||
if since == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: release-notes narrative --since vX [--to Y]")
|
||||
return 2
|
||||
}
|
||||
subjects, err := GitLogSubjects(since, to)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "narrative: git log failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Print(PlainEnglishNarrative(subjects))
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdPrependNarrative(args []string) int {
|
||||
var server, repo, token, tag, since, to string
|
||||
to = "HEAD"
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--server":
|
||||
i++
|
||||
server = arg(i, args)
|
||||
case "--repo":
|
||||
i++
|
||||
repo = arg(i, args)
|
||||
case "--token":
|
||||
i++
|
||||
token = arg(i, args)
|
||||
case "--tag":
|
||||
i++
|
||||
tag = arg(i, args)
|
||||
case "--since":
|
||||
i++
|
||||
since = arg(i, args)
|
||||
case "--to":
|
||||
i++
|
||||
to = arg(i, args)
|
||||
}
|
||||
}
|
||||
if server == "" || repo == "" || tag == "" || since == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: release-notes prepend-narrative --server S --repo R --tag vX --since vPrev [--token T] [--to Y]")
|
||||
return 2
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv("GITEA_TOKEN")
|
||||
}
|
||||
subjects, err := GitLogSubjects(since, to)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "prepend-narrative: git log failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
section := PlainEnglishNarrative(subjects)
|
||||
if section == "" {
|
||||
fmt.Println(`{"action":"noop","reason":"no plain-English narrative for range"}`)
|
||||
return 0
|
||||
}
|
||||
t := httpTransport{}
|
||||
existing := FetchRelease(t, server, repo, token, tag)
|
||||
if existing == nil {
|
||||
fmt.Fprintf(os.Stderr, "prepend-narrative: no release for tag %s\n", tag)
|
||||
return 1
|
||||
}
|
||||
em := asDict(existing)
|
||||
body := getStrDefault(existing, "body")
|
||||
if strings.Contains(body, narrativeSectionHeader) {
|
||||
fmt.Println(`{"action":"noop","reason":"narrative already present"}`)
|
||||
return 0
|
||||
}
|
||||
id := pyStr(em.Get2("id"))
|
||||
status, _ := _api(t, fmt.Sprintf("%s/api/v1/repos/%s/releases/%s", server, repo, id), token, "PATCH",
|
||||
om(KV{"body", section + "\n" + body}))
|
||||
if status != 200 {
|
||||
fmt.Fprintf(os.Stderr, "prepend-narrative: PATCH failed (HTTP %d)\n", status)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(`{"action":"prepended"}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
func arg(i int, args []string) string {
|
||||
if i < len(args) {
|
||||
return args[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -7,8 +7,16 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 2 {
|
||||
switch os.Args[1] {
|
||||
case "narrative":
|
||||
os.Exit(cmdNarrative(os.Args[2:]))
|
||||
case "prepend-narrative":
|
||||
os.Exit(cmdPrependNarrative(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
if len(os.Args) < 2 || os.Args[1] != "replay" {
|
||||
fmt.Fprintln(os.Stderr, "usage: release-notes replay <truth.json>")
|
||||
fmt.Fprintln(os.Stderr, "usage: release-notes replay <truth.json> | narrative --since vX [--to Y] | prepend-narrative --server S --repo R --tag vX --since vPrev")
|
||||
os.Exit(2)
|
||||
}
|
||||
path := "truth.json"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// narrative.go — PLAIN-ENGLISH release narrative (owner directive 2026-10:
|
||||
// "what was released … explained in plain english … so I can see what was
|
||||
// added clearly"). Net-new Go capability on top of the frozen Python-parity
|
||||
// port: derives facts from the conventional-commit range between the previous
|
||||
// tag and the new one, classifies into NEW / FIXED / IMPROVED / HOUSEKEEPING,
|
||||
// translates jargon via a deterministic map, and renders a "# What's new —
|
||||
// plain English" section that goes to the TOP of the release body.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// jargonMap — deterministic reader-language translations (ordered; first
|
||||
// match wins). Jargon with no entry falls through honestly: the noun stays,
|
||||
// the sentence stays simple.
|
||||
var jargonMap = []struct{ from, to string }{
|
||||
// compound phrases first (most specific wins)
|
||||
{"osc 8 hyperlink", "clickable links"},
|
||||
{"osc 8", "clickable links"},
|
||||
{"osc8", "clickable links"},
|
||||
{"hyperlink", "clickable links"},
|
||||
{"pty", "terminal automation tests"},
|
||||
{"semver", "version numbering"},
|
||||
{"race detector", "deep code testing"},
|
||||
{"scrollback", "chat history scrolling"},
|
||||
{"viewport", "the chat window"},
|
||||
{"tui", "the chat window"},
|
||||
{"truth battery", "frozen test battery"},
|
||||
{"idempotent", "safe to re-run"},
|
||||
{"provenance", "evidence trail"},
|
||||
{"scaffold store", "project record"},
|
||||
{"gitea", "the release server"},
|
||||
{"http surface", "web interface"},
|
||||
{"read-only surface", "view-only page"},
|
||||
}
|
||||
|
||||
// plainHousekeeping — the single line covering chore/docs/ci/test commits.
|
||||
const plainHousekeeping = "Behind-the-scenes upkeep"
|
||||
|
||||
// PlainCommit — one commit subject in the release range.
|
||||
type PlainCommit struct {
|
||||
Subject string
|
||||
}
|
||||
|
||||
// var prefixRe — conventional-commit "type(scope): subject".
|
||||
var prefixRe = regexp.MustCompile(`^(\w+)(?:\(([^)]*)\))?:\s*(.*)`)
|
||||
|
||||
// noiseRe — release-bump / merge subjects that are pipeline noise, not
|
||||
// user-facing changes.
|
||||
var noiseRe = regexp.MustCompile(`^chore\(release\): auto-bump|^Merge (remote|pull)|^sync:`)
|
||||
|
||||
// classifyCommit — prefix → (category, housekeeping?) per the wave spec:
|
||||
// feat→NEW, fix→FIXED, perf/refactor/style→IMPROVED,
|
||||
// chore/docs/ci/test/build→HOUSEKEEPING; unknown prefixes land in IMPROVED.
|
||||
func classifyCommit(subject string) (category string, housekeeping bool, rest string) {
|
||||
m := prefixRe.FindStringSubmatch(subject)
|
||||
if m == nil {
|
||||
return "IMPROVED", false, subject
|
||||
}
|
||||
kind, rest := m[1], m[3]
|
||||
switch kind {
|
||||
case "feat":
|
||||
return "NEW", false, rest
|
||||
case "fix":
|
||||
return "FIXED", false, rest
|
||||
case "perf", "refactor", "style":
|
||||
return "IMPROVED", false, rest
|
||||
default:
|
||||
// chore, docs, ci, test, build — and any unmapped prefix — are
|
||||
// upkeep, summarised as one line, never itemised.
|
||||
return "HOUSEKEEPING", true, rest
|
||||
}
|
||||
}
|
||||
|
||||
// translateJargon — apply the jargon map (case-insensitive, word-ish match).
|
||||
func translateJargon(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
for _, j := range jargonMap {
|
||||
needle := strings.ToLower(j.from)
|
||||
idx := -1
|
||||
for start := 0; start+len(needle) <= len(lower); {
|
||||
i := strings.Index(lower[start:], needle)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
i += start
|
||||
end := i + len(needle)
|
||||
beforeOK := i == 0 || !isWordByte(lower[i-1])
|
||||
afterOK := end >= len(lower) || !isWordByte(lower[end])
|
||||
if beforeOK && afterOK {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
if idx >= 0 {
|
||||
s = s[:idx] + j.to + s[idx+len(j.from):]
|
||||
lower = strings.ToLower(s)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isWordByte(b byte) bool {
|
||||
return b == '_' || (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z')
|
||||
}
|
||||
|
||||
// plainSentence — one commit subject → one reader-facing bullet.
|
||||
func plainSentence(subject string) string {
|
||||
_, _, rest := classifyCommit(subject)
|
||||
text := translateJargon(rest)
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
// collapse adjacent duplicate words ("the the") left by phrase expansion
|
||||
words := strings.Fields(text)
|
||||
var kept []string
|
||||
for _, w := range words {
|
||||
if len(kept) > 0 && strings.EqualFold(kept[len(kept)-1], w) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, w)
|
||||
}
|
||||
text = strings.Join(kept, " ")
|
||||
text = strings.Trim(text, " -,")
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
r := []rune(text)
|
||||
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
|
||||
return string(r) + "."
|
||||
}
|
||||
|
||||
// plainMaxBullets — the cap; overflow FIXED/IMPROVED items group into one
|
||||
// honest closing line.
|
||||
const plainMaxBullets = 15
|
||||
|
||||
// PlainEnglishNarrative — the "# What's new — plain English" markdown
|
||||
// section for a commit range, or "" when the range carries no user-facing
|
||||
// change. Deterministic: no clock, no randomness.
|
||||
func PlainEnglishNarrative(subjects []string) string {
|
||||
var news, fixes, improves []string
|
||||
housekeeping := 0
|
||||
for _, s := range subjects {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || noiseRe.MatchString(s) {
|
||||
continue
|
||||
}
|
||||
cat, hk := func() (string, bool) { c, h, _ := classifyCommit(s); return c, h }()
|
||||
if hk {
|
||||
housekeeping++
|
||||
continue
|
||||
}
|
||||
sentence := plainSentence(s)
|
||||
if sentence == "" {
|
||||
continue
|
||||
}
|
||||
switch cat {
|
||||
case "NEW":
|
||||
news = append(news, sentence)
|
||||
case "FIXED":
|
||||
fixes = append(fixes, sentence)
|
||||
default:
|
||||
improves = append(improves, sentence)
|
||||
}
|
||||
}
|
||||
var bullets []string
|
||||
bullets = append(bullets, news...)
|
||||
bullets = append(bullets, fixes...)
|
||||
bullets = append(bullets, improves...)
|
||||
if len(bullets) == 0 && housekeeping == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# What's new — plain English\n\n")
|
||||
shown := bullets
|
||||
if len(shown) > plainMaxBullets {
|
||||
shown = shown[:plainMaxBullets]
|
||||
}
|
||||
for _, bullet := range shown {
|
||||
b.WriteString("- " + bullet + "\n")
|
||||
}
|
||||
overflow := len(bullets) - len(shown)
|
||||
if overflow > 0 {
|
||||
b.WriteString(fmt.Sprintf("- Plus %d more small fixes and tidy-ups behind the scenes.\n", overflow))
|
||||
}
|
||||
if housekeeping > 0 {
|
||||
if overflow > 0 || len(shown) > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s: %d commit(s) of docs, tests and tooling this release.\n", plainHousekeeping, housekeeping))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GitLogSubjects — the commit subjects for range (since..to), merges and
|
||||
// pipeline noise excluded by the caller's shared filters.
|
||||
func GitLogSubjects(since, to string) ([]string, error) {
|
||||
if to == "" {
|
||||
to = "HEAD"
|
||||
}
|
||||
out, err := exec.Command("git", "log", "--format=%s", "--no-merges", since+".."+to).Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var subjects []string
|
||||
for _, ln := range strings.Split(string(out), "\n") {
|
||||
if ln = strings.TrimSpace(ln); ln != "" {
|
||||
subjects = append(subjects, ln)
|
||||
}
|
||||
}
|
||||
return subjects, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// narrative_test.go — red-first battery for the plain-English narrative
|
||||
// (owner directive: "explained in plain english … so I can see what was
|
||||
// added clearly"). Covers: translation-map hits, unmapped-jargon honesty,
|
||||
// category grouping, housekeeping summarisation, the 15-bullet cap, and
|
||||
// pipeline-noise exclusion.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNarrativeTranslationMapHits(t *testing.T) {
|
||||
got := PlainEnglishNarrative([]string{
|
||||
"feat(tui): osc 8 hyperlink support in the viewport",
|
||||
})
|
||||
want := "# What's new — plain English\n\n- Clickable links support in the chat window.\n"
|
||||
if got != want {
|
||||
t.Fatalf("GOT:\n%s\nWANT:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeUnmappedJargonFallsThroughHonestly(t *testing.T) {
|
||||
got := PlainEnglishNarrative([]string{
|
||||
"feat(x): zsh bracketed-paste widget",
|
||||
})
|
||||
if !strings.Contains(got, "- Zsh bracketed-paste widget.") {
|
||||
t.Fatalf("unmapped jargon must keep its noun, simplified sentence; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeCategoryGrouping(t *testing.T) {
|
||||
got := PlainEnglishNarrative([]string{
|
||||
"fix(tui): crash on empty chat",
|
||||
"feat(tui): chat history scrolling",
|
||||
"perf(tui): faster startup",
|
||||
})
|
||||
idxNew := strings.Index(got, "Chat history scrolling.")
|
||||
idxFix := strings.Index(got, "Crash on empty chat.")
|
||||
idxImp := strings.Index(got, "Faster startup.")
|
||||
if idxNew < 0 || idxFix < 0 || idxImp < 0 || !(idxNew < idxFix && idxFix < idxImp) {
|
||||
t.Fatalf("order must be NEW, FIXED, IMPROVED; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeHousekeepingSummarisedNotItemised(t *testing.T) {
|
||||
got := PlainEnglishNarrative([]string{
|
||||
"feat(x): clickable links",
|
||||
"docs: update readme",
|
||||
"ci: pin runner",
|
||||
"test: widen battery",
|
||||
})
|
||||
if strings.Contains(got, "- Update readme") || strings.Contains(got, "- Pin runner") {
|
||||
t.Fatalf("housekeeping must never be itemised; got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, plainHousekeeping+": 3 commit(s)") {
|
||||
t.Fatalf("housekeeping summary line missing; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeHousekeepingOnlyRangeStillRenders(t *testing.T) {
|
||||
got := PlainEnglishNarrative([]string{"docs: readme"})
|
||||
want := "# What's new — plain English\n\n" + plainHousekeeping + ": 1 commit(s) of docs, tests and tooling this release.\n"
|
||||
if got != want {
|
||||
t.Fatalf("GOT:\n%s\nWANT:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeCapGroupsOverflow(t *testing.T) {
|
||||
subjects := []string{"feat(x): headline feature"}
|
||||
for i := 0; i < 20; i++ {
|
||||
subjects = append(subjects, "fix(x): small fix")
|
||||
}
|
||||
got := PlainEnglishNarrative(subjects)
|
||||
lines := strings.Split(got, "\n")
|
||||
bullets := 0
|
||||
for _, ln := range lines {
|
||||
if strings.HasPrefix(ln, "- ") {
|
||||
bullets++
|
||||
}
|
||||
}
|
||||
if bullets > 16 { // 15 capped + 1 overflow line
|
||||
t.Fatalf("cap exceeded: %d bullets\n%s", bullets, got)
|
||||
}
|
||||
if !strings.Contains(got, "Plus 6 more small fixes") {
|
||||
t.Fatalf("overflow line wrong; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeNoiseAndEmptyRange(t *testing.T) {
|
||||
if got := PlainEnglishNarrative([]string{
|
||||
"chore(release): auto-bump version to 0.329.0 — earned by #",
|
||||
"Merge pull request #7",
|
||||
}); got != "" {
|
||||
t.Fatalf("noise-only range must render nothing; got:\n%s", got)
|
||||
}
|
||||
if got := PlainEnglishNarrative(nil); got != "" {
|
||||
t.Fatalf("empty range must render nothing; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCommitPrefixes(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
cat string
|
||||
hk bool
|
||||
rest string
|
||||
}{
|
||||
"feat(tui): links": {"NEW", false, "links"},
|
||||
"fix(tui): crash": {"FIXED", false, "crash"},
|
||||
"perf(core): speed": {"IMPROVED", false, "speed"},
|
||||
"refactor(store): seams": {"IMPROVED", false, "seams"},
|
||||
"style(tui): spacing": {"IMPROVED", false, "spacing"},
|
||||
"chore(release): bump": {"HOUSEKEEPING", true, "bump"},
|
||||
"docs(readme): text": {"HOUSEKEEPING", true, "text"},
|
||||
"ci(runner): pin": {"HOUSEKEEPING", true, "pin"},
|
||||
"test(x): widen": {"HOUSEKEEPING", true, "widen"},
|
||||
"build(x): deps": {"HOUSEKEEPING", true, "deps"},
|
||||
"unknown-prefix: unmapped": {"IMPROVED", false, "unknown-prefix: unmapped"},
|
||||
"feat(tui) no colon fallback": {"IMPROVED", false, "feat(tui) no colon fallback"},
|
||||
}
|
||||
for subject, want := range cases {
|
||||
cat, hk, rest := classifyCommit(subject)
|
||||
if cat != want.cat || hk != want.hk || rest != want.rest {
|
||||
t.Errorf("%q: got (%q,%v,%q) want (%q,%v,%q)", subject, cat, hk, rest, want.cat, want.hk, want.rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,34 @@ type anthropicSystemBlock struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// anthropicToolDef is one tool on the tools param: the schema rides as
|
||||
// input_schema (anthropic's own dialect, not the OI function wrapper).
|
||||
type anthropicToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"input_schema"`
|
||||
}
|
||||
|
||||
// anthropicContentBlock is one content block: text/thinking on the
|
||||
// reply, tool_use on a tool-calling reply, tool_result on a continued
|
||||
// user turn.
|
||||
type anthropicContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input map[string]any `json:"input,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// anthropicMessage carries EITHER a plain string content (the
|
||||
// messages-only path) or the tool-loop block array (assistant tool_use
|
||||
// replay; user tool_result continuation).
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"` // "user" | "assistant"
|
||||
Content string `json:"content"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
|
||||
type anthropicChatRequest struct {
|
||||
@@ -59,23 +84,61 @@ type anthropicChatRequest struct {
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System []anthropicSystemBlock `json:"system,omitempty"`
|
||||
Tools []anthropicToolDef `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicChatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
} `json:"content"`
|
||||
Model string `json:"model"`
|
||||
Content []anthropicContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
// Chat runs one /v1/messages call: leading system messages hoisted to
|
||||
// the system param as text blocks (first block first), the remaining
|
||||
// user/assistant history verbatim, thinking on replayed assistants
|
||||
// dropped (input thinking blocks demand tool-use config this core
|
||||
// does not send).
|
||||
// anthropicWireMessages translates history onto the anthropic wire: a
|
||||
// leading system message is handled by the caller; an assistant
|
||||
// message carrying tool calls replays as tool_use content blocks (the
|
||||
// id minted from the tool name so the paired tool_result matches); a
|
||||
// role-"tool" result continues as a user message of tool_result blocks
|
||||
// carrying the tool_use_id; plain messages keep their string content.
|
||||
func anthropicWireMessages(msgs []ChatMessage) []anthropicMessage {
|
||||
out := make([]anthropicMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
switch {
|
||||
case m.Role == "assistant" && len(m.ToolCalls) > 0:
|
||||
blocks := make([]anthropicContentBlock, 0, len(m.ToolCalls)+1)
|
||||
if strings.TrimSpace(m.Content) != "" {
|
||||
blocks = append(blocks, anthropicContentBlock{Type: "text", Text: m.Content})
|
||||
}
|
||||
for _, call := range m.ToolCalls {
|
||||
blocks = append(blocks, anthropicContentBlock{
|
||||
Type: "tool_use", ID: "toolu_" + call.Name, Name: call.Name, Input: call.Args,
|
||||
})
|
||||
}
|
||||
out = append(out, anthropicMessage{Role: "assistant", Content: blocks})
|
||||
case m.Role == "tool":
|
||||
out = append(out, anthropicMessage{Role: "user", Content: []anthropicContentBlock{{
|
||||
Type: "tool_result", ToolUseID: "toolu_" + m.ToolName, Content: m.Content,
|
||||
}}})
|
||||
default:
|
||||
out = append(out, anthropicMessage{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Chat runs one plain /v1/messages call (the toolless leg).
|
||||
func (c *AnthropicClient) Chat(ctx context.Context, model string, msgs []ChatMessage) (ChatReply, error) {
|
||||
return c.ChatWithTools(ctx, model, msgs, nil)
|
||||
}
|
||||
|
||||
// SupportsTools: the anthropic wire carries tools natively (the tools
|
||||
// param + tool_use/tool_result blocks).
|
||||
func (c *AnthropicClient) SupportsTools(ctx context.Context, model string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ChatWithTools is the tool-loop leg: the mode's toolset rides the
|
||||
// request as the tools param with input_schema; tool_use blocks parse
|
||||
// to ToolCalls; a nil toolset ships no tools param (cap-out withdrawn).
|
||||
func (c *AnthropicClient) ChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef) (ChatReply, error) {
|
||||
var system []anthropicSystemBlock
|
||||
rest := msgs
|
||||
for len(rest) > 0 && rest[0].Role == "system" {
|
||||
@@ -84,12 +147,15 @@ func (c *AnthropicClient) Chat(ctx context.Context, model string, msgs []ChatMes
|
||||
}
|
||||
rest = rest[1:]
|
||||
}
|
||||
wireMsgs := make([]anthropicMessage, 0, len(rest))
|
||||
for _, m := range rest {
|
||||
wireMsgs = append(wireMsgs, anthropicMessage{Role: m.Role, Content: m.Content})
|
||||
wireMsgs := anthropicWireMessages(rest)
|
||||
var wireTools []anthropicToolDef
|
||||
for _, t := range tools {
|
||||
wireTools = append(wireTools, anthropicToolDef{
|
||||
Name: t.Function.Name, Description: t.Function.Description, InputSchema: t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
body, err := json.Marshal(anthropicChatRequest{
|
||||
Model: model, MaxTokens: anthropicMaxTokens, Messages: wireMsgs, System: system,
|
||||
Model: model, MaxTokens: anthropicMaxTokens, Messages: wireMsgs, System: system, Tools: wireTools,
|
||||
})
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
@@ -123,6 +189,8 @@ func (c *AnthropicClient) Chat(ctx context.Context, model string, msgs []ChatMes
|
||||
if strings.TrimSpace(blk.Thinking) != "" {
|
||||
reply.Thinking = append(reply.Thinking, blk.Thinking)
|
||||
}
|
||||
case "tool_use":
|
||||
reply.ToolCalls = append(reply.ToolCalls, ToolCall{Name: blk.Name, Args: blk.Input})
|
||||
}
|
||||
}
|
||||
reply.Content = content.String()
|
||||
|
||||
@@ -37,7 +37,34 @@ func NewGeminiClient(baseURL, apiKey, provider string) *GeminiClient {
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text"`
|
||||
Text string `json:"text"`
|
||||
Thought bool `json:"thought,omitempty"`
|
||||
// tool-loop shapes (the D45 follow-up): functionCall on a model
|
||||
// turn, functionResponse on a continued user turn — keyed by
|
||||
// function NAME (gemini's own dialect; no call ids on this wire).
|
||||
FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResp *geminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
}
|
||||
|
||||
type geminiFunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
|
||||
type geminiFunctionResponse struct {
|
||||
Name string `json:"name"`
|
||||
Response map[string]any `json:"response"`
|
||||
}
|
||||
|
||||
// geminiTool is one entry under the tools param: the declarations list.
|
||||
type geminiTool struct {
|
||||
FunctionDeclarations []geminiFunctionDecl `json:"functionDeclarations"`
|
||||
}
|
||||
|
||||
type geminiFunctionDecl struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
@@ -50,21 +77,60 @@ type geminiChatRequest struct {
|
||||
SystemInstruction *struct {
|
||||
Parts []geminiPart `json:"parts"`
|
||||
} `json:"systemInstruction,omitempty"`
|
||||
Tools []geminiTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type geminiChatResponse struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Role string `json:"role"`
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
Thought bool `json:"thought"`
|
||||
} `json:"parts"`
|
||||
Role string `json:"role"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
ModelVersion string `json:"modelVersion"`
|
||||
}
|
||||
|
||||
// geminiWireMessages translates history onto the gemini wire: the
|
||||
// assistant role maps to "model"; an assistant message carrying tool
|
||||
// calls replays as functionCall parts; a role-"tool" result continues
|
||||
// as a user functionResponse part keyed by function name.
|
||||
func geminiWireMessages(msgs []ChatMessage) []geminiContent {
|
||||
out := make([]geminiContent, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
role := m.Role
|
||||
switch role {
|
||||
case "assistant":
|
||||
role = "model" // gemini's spelling
|
||||
case "tool":
|
||||
role = "user" // a tool result continues as a user turn
|
||||
}
|
||||
var parts []geminiPart
|
||||
switch {
|
||||
case m.Role == "assistant" && len(m.ToolCalls) > 0:
|
||||
if strings.TrimSpace(m.Content) != "" {
|
||||
parts = append(parts, geminiPart{Text: m.Content})
|
||||
}
|
||||
for _, call := range m.ToolCalls {
|
||||
parts = append(parts, geminiPart{FunctionCall: &geminiFunctionCall{Name: call.Name, Args: call.Args}})
|
||||
}
|
||||
case m.Role == "tool":
|
||||
parts = append(parts, geminiPart{FunctionResp: &geminiFunctionResponse{
|
||||
Name: m.ToolName,
|
||||
Response: map[string]any{"result": m.Content},
|
||||
}})
|
||||
default:
|
||||
parts = append(parts, geminiPart{Text: m.Content})
|
||||
}
|
||||
out = append(out, geminiContent{Role: role, Parts: parts})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Chat runs one plain generateContent call (the toolless leg).
|
||||
func (c *GeminiClient) Chat(ctx context.Context, model string, msgs []ChatMessage) (ChatReply, error) {
|
||||
return c.ChatWithTools(ctx, model, msgs, nil)
|
||||
}
|
||||
|
||||
// geminiWireModel normalizes the model id onto the wire's models/
|
||||
// route ("gemini-x" and "models/gemini-x" both answer
|
||||
// models/gemini-x:generateContent).
|
||||
@@ -72,8 +138,16 @@ func geminiWireModel(model string) string {
|
||||
return "models/" + strings.TrimPrefix(strings.TrimPrefix(model, "models/"), "/")
|
||||
}
|
||||
|
||||
// Chat runs one generateContent call.
|
||||
func (c *GeminiClient) Chat(ctx context.Context, model string, msgs []ChatMessage) (ChatReply, error) {
|
||||
// SupportsTools: the gemini wire carries tools natively
|
||||
// (functionDeclarations under the tools param).
|
||||
func (c *GeminiClient) SupportsTools(ctx context.Context, model string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ChatWithTools is the tool-loop leg: the mode's toolset rides the
|
||||
// request as functionDeclarations; functionCall parts parse to
|
||||
// ToolCalls; a nil toolset ships no tools param (cap-out withdrawn).
|
||||
func (c *GeminiClient) ChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef) (ChatReply, error) {
|
||||
var system *struct {
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
@@ -89,15 +163,18 @@ func (c *GeminiClient) Chat(ctx context.Context, model string, msgs []ChatMessag
|
||||
}
|
||||
rest = rest[1:]
|
||||
}
|
||||
contents := make([]geminiContent, 0, len(rest))
|
||||
for _, m := range rest {
|
||||
role := m.Role
|
||||
if role == "assistant" {
|
||||
role = "model" // gemini's spelling
|
||||
contents := geminiWireMessages(rest)
|
||||
var wireTools []geminiTool
|
||||
if len(tools) > 0 {
|
||||
decls := make([]geminiFunctionDecl, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
decls = append(decls, geminiFunctionDecl{
|
||||
Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
contents = append(contents, geminiContent{Role: role, Parts: []geminiPart{{Text: m.Content}}})
|
||||
wireTools = []geminiTool{{FunctionDeclarations: decls}}
|
||||
}
|
||||
body, err := json.Marshal(geminiChatRequest{Contents: contents, SystemInstruction: system})
|
||||
body, err := json.Marshal(geminiChatRequest{Contents: contents, SystemInstruction: system, Tools: wireTools})
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
@@ -123,6 +200,10 @@ func (c *GeminiClient) Chat(ctx context.Context, model string, msgs []ChatMessag
|
||||
var content strings.Builder
|
||||
if len(out.Candidates) > 0 {
|
||||
for _, part := range out.Candidates[0].Content.Parts {
|
||||
if part.FunctionCall != nil {
|
||||
reply.ToolCalls = append(reply.ToolCalls, ToolCall{Name: part.FunctionCall.Name, Args: part.FunctionCall.Args})
|
||||
continue
|
||||
}
|
||||
if part.Thought {
|
||||
if strings.TrimSpace(part.Text) != "" {
|
||||
reply.Thinking = append(reply.Thinking, part.Text)
|
||||
|
||||
@@ -48,19 +48,42 @@ type oiMessage struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// oiWireMessage is one history entry on the [OI] wire: a plain
|
||||
// message, or the tool-loop shapes (the assistant's tool_calls; a
|
||||
// role-"tool" result carrying its tool name).
|
||||
type oiWireMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []oiWireToolCall `json:"tool_calls,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// oiWireToolCall mirrors choices[0].message.tool_calls: the JSON
|
||||
// arguments ride as a STRING on this wire (never a nested object).
|
||||
type oiWireToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
type oiChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []oiMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Model string `json:"model"`
|
||||
Messages []oiWireMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Tools []ToolDef `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type oiChatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content *string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Role string `json:"role"`
|
||||
Content *string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []oiWireToolCall `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
@@ -75,15 +98,61 @@ func bareWireModel(model string) string {
|
||||
return model
|
||||
}
|
||||
|
||||
// Chat runs one /chat/completions call: the full history
|
||||
// (system/user/assistant, thinking dropped — not a wire field),
|
||||
// non-streaming, the reply's reasoning surfaced when reported.
|
||||
// Chat runs one /chat/completions call: the plain (toolless) leg.
|
||||
func (c *OpenAICompatClient) Chat(ctx context.Context, model string, msgs []ChatMessage) (ChatReply, error) {
|
||||
wireMsgs := make([]oiMessage, 0, len(msgs))
|
||||
return c.ChatWithTools(ctx, model, msgs, nil)
|
||||
}
|
||||
|
||||
// SupportsTools: the OpenAI-compat wire carries tools natively (the
|
||||
// same tools array the local Ollama wire speaks — the dominant cloud
|
||||
// dialect). An incapability lives with the MODEL, not the wire: a
|
||||
// model that never answers with tool_calls just runs the plain path.
|
||||
func (c *OpenAICompatClient) SupportsTools(ctx context.Context, model string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// oiWireMessages translates history onto the OpenAI wire: plain roles
|
||||
// verbatim (thinking dropped — not an OI field), the assistant's tool
|
||||
// calls as tool_calls with JSON-string arguments, a role-"tool" result
|
||||
// as role "tool" carrying its tool name.
|
||||
func oiWireMessages(msgs []ChatMessage) ([]oiWireMessage, error) {
|
||||
out := make([]oiWireMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
wireMsgs = append(wireMsgs, oiMessage{Role: m.Role, Content: m.Content})
|
||||
wm := oiWireMessage{Role: m.Role, Content: m.Content}
|
||||
switch {
|
||||
case m.Role == "assistant" && len(m.ToolCalls) > 0:
|
||||
for i, call := range m.ToolCalls {
|
||||
var args []byte
|
||||
if call.Args != nil {
|
||||
args, _ = json.Marshal(call.Args)
|
||||
}
|
||||
wm.ToolCalls = append(wm.ToolCalls, oiWireToolCall{
|
||||
ID: fmt.Sprintf("call_%d", i),
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{Name: call.Name, Arguments: string(args)},
|
||||
})
|
||||
}
|
||||
case m.Role == "tool":
|
||||
wm.Name = m.ToolName
|
||||
}
|
||||
out = append(out, wm)
|
||||
}
|
||||
body, err := json.Marshal(oiChatRequest{Model: bareWireModel(model), Messages: wireMsgs, Stream: false})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChatWithTools is the tool-loop leg (D45 follow-up): the mode's
|
||||
// toolset rides the request; the response's tool_calls parse into the
|
||||
// reply. A nil toolset ships no tools param (the plain call, cap-out
|
||||
// withdrawn).
|
||||
func (c *OpenAICompatClient) ChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef) (ChatReply, error) {
|
||||
wireMsgs, err := oiWireMessages(msgs)
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
body, err := json.Marshal(oiChatRequest{Model: bareWireModel(model), Messages: wireMsgs, Stream: false, Tools: tools})
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
@@ -109,11 +178,22 @@ func (c *OpenAICompatClient) Chat(ctx context.Context, model string, msgs []Chat
|
||||
}
|
||||
reply := ChatReply{Model: out.Model}
|
||||
if len(out.Choices) > 0 {
|
||||
if out.Choices[0].Message.Content != nil {
|
||||
reply.Content = *out.Choices[0].Message.Content
|
||||
msg := out.Choices[0].Message
|
||||
if msg.Content != nil {
|
||||
reply.Content = *msg.Content
|
||||
}
|
||||
if strings.TrimSpace(out.Choices[0].Message.ReasoningContent) != "" {
|
||||
reply.Thinking = []string{out.Choices[0].Message.ReasoningContent}
|
||||
if strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
reply.Thinking = []string{msg.ReasoningContent}
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
call := ToolCall{Name: tc.Function.Name}
|
||||
if strings.TrimSpace(tc.Function.Arguments) != "" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(tc.Function.Arguments), &m) == nil {
|
||||
call.Args = m
|
||||
}
|
||||
}
|
||||
reply.ToolCalls = append(reply.ToolCalls, call)
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package runecore
|
||||
|
||||
// cloud_tools_test.go — the D45 named follow-up: native tool calls on
|
||||
// the three cloud wires, red-first against scripted httptest doubles.
|
||||
// Request shape asserted (the toolset rides the wire in each
|
||||
// provider's own dialect), response shapes exercised (plain answer,
|
||||
// tool call, continuation replay). Hermetic — never a live provider
|
||||
// (the live leg stays RUNE_CLOUD_PARITY_LIVE=1-gated for the owner).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── the OpenAI-compat wire: tools array in, tool_calls out ────────────
|
||||
|
||||
func TestOpenAICompatChatWithTools(t *testing.T) {
|
||||
d := startWireDouble(t, func(path string, r *http.Request) (int, string) {
|
||||
if path != "/v1/chat/completions" {
|
||||
return http.StatusNotFound, `{"error":{"message":"wrong path"}}`
|
||||
}
|
||||
return http.StatusOK, `{"model":"glm-5.3-flash","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/notes.txt\"}"}}]}}]}`
|
||||
})
|
||||
c := NewOpenAICompatClient(d.ts.URL+"/v1", "sk-test-key", "moonshot")
|
||||
if !c.SupportsTools(context.Background(), "glm-5.3-flash") {
|
||||
t.Fatal("the OpenAI-compat wire carries tools natively — SupportsTools must be true")
|
||||
}
|
||||
reply, err := c.ChatWithTools(context.Background(), "moonshot/glm-5.3-flash",
|
||||
[]ChatMessage{{Role: "user", Content: "read the notes"}}, ToolsetFor("CHAT"))
|
||||
if err != nil {
|
||||
t.Fatalf("chat with tools: %v", err)
|
||||
}
|
||||
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" {
|
||||
t.Fatalf("tool calls = %+v, want read_file parsed", reply.ToolCalls)
|
||||
}
|
||||
if reply.ToolCalls[0].Args["path"] != "/tmp/notes.txt" {
|
||||
t.Fatalf("args = %+v, want the JSON arguments decoded", reply.ToolCalls[0].Args)
|
||||
}
|
||||
|
||||
reqs := d.requests()
|
||||
var body struct {
|
||||
Tools []struct {
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"function"`
|
||||
} `json:"tools"`
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reqs[0].Body), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, tl := range body.Tools {
|
||||
if tl.Type == "function" && tl.Function.Name == "read_file" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tools did not ride the request: %s", reqs[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// RED — the continuation: the assistant's tool_calls and the tool
|
||||
// result replay in history (assistant tool_calls + role "tool" message
|
||||
// carrying the envelope JSON).
|
||||
func TestOpenAICompatToolContinuationReplay(t *testing.T) {
|
||||
d := startWireDouble(t, func(path string, r *http.Request) (int, string) {
|
||||
return http.StatusOK, `{"model":"m","choices":[{"message":{"content":"final answer"}}]}`
|
||||
})
|
||||
c := NewOpenAICompatClient(d.ts.URL+"/v1", "k", "moonshot")
|
||||
history := []ChatMessage{
|
||||
{Role: "user", Content: "read the notes"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{Name: "read_file", Args: map[string]any{"path": "/tmp/notes.txt"}}}},
|
||||
{Role: "tool", Content: `{"success":true,"result":"the notes"}`, ToolName: "read_file"},
|
||||
}
|
||||
if _, err := c.ChatWithTools(context.Background(), "m", history, nil); err != nil {
|
||||
t.Fatalf("continuation: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(d.requests()[0].Body), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body.Messages) != 3 {
|
||||
t.Fatalf("messages = %+v, want the full replayed history", body.Messages)
|
||||
}
|
||||
if len(body.Messages[1].ToolCalls) != 1 || body.Messages[1].ToolCalls[0].Function.Name != "read_file" {
|
||||
t.Fatalf("assistant tool_calls did not replay: %+v", body.Messages[1])
|
||||
}
|
||||
if body.Messages[2].Role != "tool" || !strings.Contains(body.Messages[2].Content, "the notes") {
|
||||
t.Fatalf("tool result did not replay as role tool: %+v", body.Messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
// RED — a plain answer through ChatWithTools: content, no tool calls.
|
||||
func TestOpenAICompatChatWithToolsPlainAnswer(t *testing.T) {
|
||||
d := startWireDouble(t, func(path string, r *http.Request) (int, string) {
|
||||
return http.StatusOK, `{"model":"m","choices":[{"message":{"content":"just text"}}]}`
|
||||
})
|
||||
c := NewOpenAICompatClient(d.ts.URL+"/v1", "k", "moonshot")
|
||||
reply, err := c.ChatWithTools(context.Background(), "m",
|
||||
[]ChatMessage{{Role: "user", Content: "hi"}}, ToolsetFor("CHAT"))
|
||||
if err != nil {
|
||||
t.Fatalf("chat: %v", err)
|
||||
}
|
||||
if reply.Content != "just text" || len(reply.ToolCalls) != 0 {
|
||||
t.Fatalf("reply = %+v, want a plain toolless answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the anthropic wire: tools param, tool_use blocks, tool_result ───
|
||||
|
||||
func TestAnthropicChatWithTools(t *testing.T) {
|
||||
d := startWireDouble(t, func(path string, r *http.Request) (int, string) {
|
||||
if path != "/v1/messages" {
|
||||
return http.StatusNotFound, `{}`
|
||||
}
|
||||
return http.StatusOK, `{"model":"","content":[{"type":"tool_use","id":"toolu_01","name":"read_file","input":{"path":"/tmp/notes.txt"}}]}`
|
||||
})
|
||||
c := NewAnthropicClient(d.ts.URL, "sk-ant-key", "anthropic")
|
||||
if !c.SupportsTools(context.Background(), "claude") {
|
||||
t.Fatal("the anthropic wire carries tools natively — SupportsTools must be true")
|
||||
}
|
||||
reply, err := c.ChatWithTools(context.Background(), "claude",
|
||||
[]ChatMessage{{Role: "user", Content: "read the notes"}}, ToolsetFor("CHAT"))
|
||||
if err != nil {
|
||||
t.Fatalf("chat with tools: %v", err)
|
||||
}
|
||||
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" || reply.ToolCalls[0].Args["path"] != "/tmp/notes.txt" {
|
||||
t.Fatalf("tool calls = %+v, want tool_use parsed", reply.ToolCalls)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"input_schema"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(d.requests()[0].Body), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, tl := range body.Tools {
|
||||
if tl.Name == "read_file" && tl.InputSchema != nil {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tools with input_schema did not ride the request: %s", d.requests()[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// RED — the continuation: the assistant's tool_use blocks replay as
|
||||
// content blocks; the result continues as a user message of tool_result
|
||||
// blocks carrying the tool_use_id.
|
||||
func TestAnthropicToolContinuationReplay(t *testing.T) {
|
||||
d := startWireDouble(t, func(path string, r *http.Request) (int, string) {
|
||||
return http.StatusOK, `{"model":"","content":[{"type":"text","text":"final answer"}]}`
|
||||
})
|
||||
c := NewAnthropicClient(d.ts.URL, "k", "anthropic")
|
||||
history := []ChatMessage{
|
||||
{Role: "user", Content: "read the notes"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{Name: "read_file", Args: map[string]any{"path": "/tmp/notes.txt"}}}},
|
||||
{Role: "tool", Content: `{"success":true,"result":"the notes"}`, ToolName: "read_file"},
|
||||
}
|
||||
if _, err := c.ChatWithTools(context.Background(), "claude", history, nil); err != nil {
|
||||
t.Fatalf("continuation: %v", err)
|
||||
}
|
||||
type blk struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input map[string]any `json:"input,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
var body struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(d.requests()[0].Body), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body.Messages) != 3 {
|
||||
t.Fatalf("messages = %+v, want the full replayed history", body.Messages)
|
||||
}
|
||||
var toolUseBlocks []blk
|
||||
if err := json.Unmarshal(body.Messages[1].Content, &toolUseBlocks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Messages[1].Role != "assistant" || len(toolUseBlocks) != 1 ||
|
||||
toolUseBlocks[0].Type != "tool_use" || toolUseBlocks[0].Name != "read_file" ||
|
||||
toolUseBlocks[0].Input["path"] != "/tmp/notes.txt" {
|
||||
t.Fatalf("assistant tool_use replay = %+v", body.Messages[1])
|
||||
}
|
||||
var resultBlocks []blk
|
||||
if err := json.Unmarshal(body.Messages[2].Content, &resultBlocks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Messages[2].Role != "user" || len(resultBlocks) != 1 ||
|
||||
resultBlocks[0].Type != "tool_result" || resultBlocks[0].ToolUseID == "" ||
|
||||
resultBlocks[0].ToolUseID != toolUseBlocks[0].ID {
|
||||
t.Fatalf("tool_result replay = %+v (want tool_use_id %q)", body.Messages[2], toolUseBlocks[0].ID)
|
||||
}
|
||||
if !strings.Contains(resultBlocks[0].Content, "the notes") {
|
||||
t.Fatalf("tool_result content = %+v, want the envelope", resultBlocks[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── the gemini wire: functionDeclarations, functionCall parts ───────
|
||||
|
||||
func TestGeminiChatWithTools(t *testing.T) {
|
||||
d := startWireDouble(t, func(path string, r *http.Request) (int, string) {
|
||||
return http.StatusOK, `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read_file","args":{"path":"/tmp/notes.txt"}}}]}}],"modelVersion":"gemini-2.5-pro"}`
|
||||
})
|
||||
c := NewGeminiClient(d.ts.URL+"/v1beta", "gem-key", "google")
|
||||
if !c.SupportsTools(context.Background(), "gemini-2.5-pro") {
|
||||
t.Fatal("the gemini wire carries tools natively — SupportsTools must be true")
|
||||
}
|
||||
reply, err := c.ChatWithTools(context.Background(), "gemini-2.5-pro",
|
||||
[]ChatMessage{{Role: "user", Content: "read the notes"}}, ToolsetFor("CHAT"))
|
||||
if err != nil {
|
||||
t.Fatalf("chat with tools: %v", err)
|
||||
}
|
||||
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" || reply.ToolCalls[0].Args["path"] != "/tmp/notes.txt" {
|
||||
t.Fatalf("tool calls = %+v, want functionCall parsed", reply.ToolCalls)
|
||||
}
|
||||
var body struct {
|
||||
Tools []struct {
|
||||
FunctionDeclarations []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"functionDeclarations"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(d.requests()[0].Body), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, tl := range body.Tools {
|
||||
for _, fd := range tl.FunctionDeclarations {
|
||||
if fd.Name == "read_file" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("functionDeclarations did not ride the request: %s", d.requests()[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// RED — the continuation: the model's functionCall part replays as a
|
||||
// model content; the result continues as a user functionResponse part
|
||||
// keyed by function name.
|
||||
func TestGeminiToolContinuationReplay(t *testing.T) {
|
||||
d := startWireDouble(t, func(path string, r *http.Request) (int, string) {
|
||||
return http.StatusOK, `{"candidates":[{"content":{"parts":[{"text":"final answer"}]}}],"modelVersion":"m"}`
|
||||
})
|
||||
c := NewGeminiClient(d.ts.URL+"/v1beta", "k", "google")
|
||||
history := []ChatMessage{
|
||||
{Role: "user", Content: "read the notes"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{Name: "read_file", Args: map[string]any{"path": "/tmp/notes.txt"}}}},
|
||||
{Role: "tool", Content: `{"success":true,"result":"the notes"}`, ToolName: "read_file"},
|
||||
}
|
||||
if _, err := c.ChatWithTools(context.Background(), "gemini-2.5-pro", history, nil); err != nil {
|
||||
t.Fatalf("continuation: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Contents []struct {
|
||||
Role string `json:"role"`
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
FunctionCall *struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args"`
|
||||
} `json:"functionCall"`
|
||||
FunctionResponse *struct {
|
||||
Name string `json:"name"`
|
||||
Response map[string]any `json:"response"`
|
||||
} `json:"functionResponse"`
|
||||
} `json:"parts"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(d.requests()[0].Body), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body.Contents) != 3 {
|
||||
t.Fatalf("contents = %+v, want the full replayed history", body.Contents)
|
||||
}
|
||||
call := body.Contents[1]
|
||||
if call.Role != "model" || len(call.Parts) != 1 || call.Parts[0].FunctionCall == nil ||
|
||||
call.Parts[0].FunctionCall.Name != "read_file" {
|
||||
t.Fatalf("functionCall replay = %+v", call)
|
||||
}
|
||||
resp := body.Contents[2]
|
||||
if resp.Role != "user" || len(resp.Parts) != 1 || resp.Parts[0].FunctionResponse == nil ||
|
||||
resp.Parts[0].FunctionResponse.Name != "read_file" {
|
||||
t.Fatalf("functionResponse replay = %+v, want a user part keyed by function name", resp)
|
||||
}
|
||||
if raw, _ := json.Marshal(resp.Parts[0].FunctionResponse.Response); !strings.Contains(string(raw), "the notes") {
|
||||
t.Fatalf("functionResponse payload = %s, want the envelope", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the engine-level runtime effect: a cloud record SERVES a tool turn
|
||||
|
||||
// RED — the D45 follow-up end-to-end on a cloud wire: a moonshot record
|
||||
// routes to the [OI] double; the model asks for read_file; the engine's
|
||||
// gate executes it (fresh mode read) and the continuation answers from
|
||||
// the file's bytes. Hermetic: the "provider" is a wire double, the
|
||||
// file lives in the test's temp dir.
|
||||
func TestEngineCloudToolTurnEndToEnd(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := OpenTailStore(filepath.Join(dir, "conversations"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
home := filepath.Join(dir, "home")
|
||||
if err := writeTestProvider(home, "moonshot", "kimi-k3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notePath := filepath.Join(dir, "notes.txt")
|
||||
if err := os.WriteFile(notePath, []byte("secret-cloud-notes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
var d *wireDouble
|
||||
d = startWireDouble(t, func(path string, r *http.Request) (int, string) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return http.StatusOK, `{"model":"kimi-k3","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"` + notePath + `\"}"}}]}}]}`
|
||||
}
|
||||
// the continuation must carry the tool result with the file's bytes
|
||||
reqs := d.requests()
|
||||
if !strings.Contains(reqs[len(reqs)-1].Body, "secret-cloud-notes") {
|
||||
t.Errorf("continuation did not carry the tool result: %s", reqs[len(reqs)-1].Body)
|
||||
}
|
||||
return http.StatusOK, `{"model":"kimi-k3","choices":[{"message":{"content":"the notes say secret-cloud-notes"}}]}`
|
||||
})
|
||||
reg := filepath.Join(dir, "providers.json")
|
||||
if err := os.WriteFile(reg, []byte(`{"providers":{"moonshot":{"type":"openai","url":"`+
|
||||
d.ts.URL+`/v1","api_key_ref":"provider/moonshot","enabled":true}}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := NewProviderRouterAt(reg, func(string) (string, error) { return "vault-key", nil })
|
||||
eng := NewRoutedEngine(st, &fakeModel{replies: map[string]string{"": "LOCAL-MISROUTE"}}, home, router)
|
||||
res, err := eng.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "read the notes"})
|
||||
if err != nil {
|
||||
t.Fatalf("cloud tool turn: %v", err)
|
||||
}
|
||||
if !strings.Contains(res.AssistantText, "secret-cloud-notes") {
|
||||
t.Fatalf("assistant = %q, want the answer fed by the tool result", res.AssistantText)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ I2 native surfaces (served from the subsystems' real on-disk state):
|
||||
full expansion: chain/action/evidence/
|
||||
waits/blocks/history — no hidden delegation
|
||||
GET /v1/events SSE over the event-bus journal (?last=N
|
||||
history replay; ?type=a,b event-type filter)
|
||||
replay, retention-capped with an honest
|
||||
gap comment; bounded queue: overflow
|
||||
counted in ": queue" comments, semantic
|
||||
@@ -115,10 +116,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 +276,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
|
||||
|
||||
@@ -467,11 +467,37 @@ func fileSize(path string) int64 {
|
||||
return st.Size()
|
||||
}
|
||||
|
||||
// journalLineMatchesType reports whether one journal line passes the
|
||||
// ?type= filter (comma-separated event types; an empty set passes all).
|
||||
// A line whose event_type cannot be parsed is dropped when a filter is
|
||||
// active — the filter is a positive request, not a default-allow.
|
||||
func journalLineMatchesType(line string, types map[string]bool) bool {
|
||||
if len(types) == 0 {
|
||||
return true
|
||||
}
|
||||
var env struct {
|
||||
EventType string `json:"event_type"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &env) != nil {
|
||||
return false
|
||||
}
|
||||
return types[env.EventType]
|
||||
}
|
||||
|
||||
func (f *frontDoor) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/events" {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
// ?type=a,b — the TUI's receipts/degrade/trust live feeds ride this
|
||||
// same journal poll with a positive event-type filter (both the
|
||||
// replay and the live tail).
|
||||
types := map[string]bool{}
|
||||
for _, t := range strings.Split(r.URL.Query().Get("type"), ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
types[t] = true
|
||||
}
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
errorEnvelope(w, http.StatusInternalServerError, "internal_error", "streaming unsupported")
|
||||
@@ -499,9 +525,12 @@ func (f *frontDoor) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
replay = replay[len(replay)-eventsReplayRetention:]
|
||||
}
|
||||
for _, line := range replay {
|
||||
if !journalLineMatchesType(line, types) {
|
||||
continue
|
||||
}
|
||||
sseData(w, flusher, line)
|
||||
}
|
||||
if missing := n - len(replay); missing > 0 {
|
||||
if missing := n - len(replay); missing > 0 && len(types) == 0 {
|
||||
why := "retention cap " + strconv.Itoa(eventsReplayRetention)
|
||||
if len(history) < n {
|
||||
why = "journal holds " + strconv.Itoa(len(history))
|
||||
@@ -530,7 +559,9 @@ func (f *frontDoor) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
for {
|
||||
if lines, next := tailJournalLines(p.journal, offset); len(lines) > 0 || next > offset {
|
||||
for _, line := range lines {
|
||||
q.add(line)
|
||||
if journalLineMatchesType(line, types) {
|
||||
q.add(line)
|
||||
}
|
||||
}
|
||||
for _, line := range q.takeAll() {
|
||||
sseData(w, flusher, line)
|
||||
|
||||
@@ -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))
|
||||
@@ -380,6 +390,57 @@ func TestEventsSSEReplaysThenTailsLiveJournal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventsSSETypeFilter: the TUI's receipts/degrade/trust live feeds
|
||||
// ride the same journal poll with a ?type= comma-separated event-type
|
||||
// filter — both the replay leg and the live tail deliver matching types
|
||||
// only.
|
||||
func TestEventsSSETypeFilter(t *testing.T) {
|
||||
ts := newTestSurface(t, nil)
|
||||
ts.plant(t, "journal",
|
||||
journalLine("evt_01", "user.message", "2026-10-16T00:00:01+00:00")+"\n"+
|
||||
journalLine("evt_02", "task.queue.enqueue", "2026-10-16T00:00:02+00:00")+"\n"+
|
||||
journalLine("evt_03", "trust.verify", "2026-10-16T00:00:03+00:00")+"\n")
|
||||
|
||||
server := httptest.NewServer(ts.front.mux())
|
||||
defer server.Close()
|
||||
|
||||
lines, stop := openSSE(t, server.URL+"/v1/events?type=task.queue.enqueue,trust.verify&last=10")
|
||||
defer stop()
|
||||
|
||||
first := readSSELine(t, lines, 2*time.Second)
|
||||
if !strings.Contains(first, `"evt_02"`) {
|
||||
t.Fatalf("replay must filter to matching types, got %q", first)
|
||||
}
|
||||
second := readSSELine(t, lines, 2*time.Second)
|
||||
if !strings.Contains(second, `"evt_03"`) {
|
||||
t.Fatalf("second matching type must replay, got %q", second)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(ts.canvas["journal"], os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString(
|
||||
journalLine("evt_04", "user.message", "2026-10-16T00:00:04+00:00") + "\n" +
|
||||
journalLine("evt_05", "task.queue.enqueue", "2026-10-16T00:00:05+00:00") + "\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
third := readSSELine(t, lines, 3*time.Second)
|
||||
if !strings.Contains(third, `"evt_05"`) {
|
||||
t.Fatalf("live tail must filter to matching types, got %q", third)
|
||||
}
|
||||
select {
|
||||
case l, ok := <-lines:
|
||||
if ok && strings.HasPrefix(l, "data: ") && strings.Contains(l, "user.message") {
|
||||
t.Fatalf("non-matching live events must not stream, got %q", l)
|
||||
}
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
// quiet — correct
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsSSEHonestWhenJournalMissing(t *testing.T) {
|
||||
ts := newTestSurface(t, nil) // no journal planted
|
||||
|
||||
@@ -418,7 +479,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 +559,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -243,6 +282,30 @@ func (f *frontDoor) handleTaskQueueEnqueue(w http.ResponseWriter, r *http.Reques
|
||||
f.delegateTaskCreate(w, p, text, req.Model)
|
||||
return
|
||||
}
|
||||
// Consent enqueue-loop idempotency (carried-repairs gap): the TUI's
|
||||
// consentBusy guard clears after the HTTP round-trip, so a re-answered
|
||||
// brief POSTs the identical (origin, text) again and stacked
|
||||
// duplicates. A repeat within the freshness window is a no-op that
|
||||
// returns the existing entry_id.
|
||||
if dup := duplicatePendingEntry(store, req.Origin, text); dup != nil {
|
||||
pos := 0
|
||||
for i := range store.Entries {
|
||||
if store.Entries[i].EntryID == dup.EntryID {
|
||||
pos = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
f.queueMu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true, "action": "queued", "queued": true, "duplicate": true,
|
||||
"entry_id": dup.EntryID, "position": pos, "count": len(store.Entries),
|
||||
"entry": map[string]any{
|
||||
"entry_id": dup.EntryID, "text": dup.Text,
|
||||
"queued_at": dup.QueuedAt, "origin": dup.Origin,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
entry := queueEntry{
|
||||
EntryID: "q_" + newCommandID(),
|
||||
Text: text,
|
||||
@@ -277,6 +340,137 @@ func (f *frontDoor) handleTaskQueueEnqueue(w http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
// duplicatePendingEntry finds a pending entry with the same (origin,
|
||||
// text) stamped within the freshness window — the bounded idempotency
|
||||
// scan behind the consent enqueue-loop no-op.
|
||||
func duplicatePendingEntry(store *queueStore, origin, text string) *queueEntry {
|
||||
for i := range store.Entries {
|
||||
e := &store.Entries[i]
|
||||
if e.Origin == origin && e.Text == text && entryQueuedFresh(e.QueuedAt) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// entryQueuedFresh reports whether one entry's queued_at names recent
|
||||
// work. An empty or unparseable stamp FAILS CLOSED (dedupes) — the
|
||||
// mirror of occupyingRowFresh.
|
||||
func entryQueuedFresh(stamp string) bool {
|
||||
if stamp == "" {
|
||||
return true
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, stamp); err == nil {
|
||||
return queueNow().Sub(t) <= queueOccupyFresh
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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,240 @@ 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"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskQueueEnqueueDuplicatePendingIsNoOp is the carried-repairs red:
|
||||
// the TUI's consentBusy guard clears after the HTTP round-trip, so a
|
||||
// re-answered brief POSTs again and stacked duplicate pending entries.
|
||||
// A repeat enqueue of an identical (origin, text) within the freshness
|
||||
// window must be a no-op returning the existing entry_id — no second
|
||||
// store append, no second journal envelope — while a distinct text still
|
||||
// enqueues.
|
||||
func TestTaskQueueEnqueueDuplicatePendingIsNoOp(t *testing.T) {
|
||||
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("a duplicate enqueue must not touch the core")
|
||||
})
|
||||
ts.plant(t, "tasks", awaitingApprovalCheckpoint())
|
||||
|
||||
_, first := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "build the scheduler", "origin": "consent_brief"}`)
|
||||
id1, _ := first["entry_id"].(string)
|
||||
if id1 == "" {
|
||||
t.Fatalf("first enqueue must return entry_id, got %v", first)
|
||||
}
|
||||
|
||||
_, second := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "build the scheduler", "origin": "consent_brief"}`)
|
||||
if second["action"] != "queued" {
|
||||
t.Fatalf("repeat enqueue must stay a queued answer, got %v", second)
|
||||
}
|
||||
id2, _ := second["entry_id"].(string)
|
||||
if id2 != id1 {
|
||||
t.Fatalf("duplicate enqueue must be a no-op returning the existing entry_id: first %q second %q", id1, id2)
|
||||
}
|
||||
if second["duplicate"] != true {
|
||||
t.Fatalf("the no-op must say duplicate:true, got %v", second)
|
||||
}
|
||||
|
||||
store, ok := readQueueStore(ts.canvas["queue"])
|
||||
if !ok || len(store.Entries) != 1 {
|
||||
t.Fatalf("duplicate must not append: entries=%d ok=%v", len(store.Entries), ok)
|
||||
}
|
||||
if envs := countQueueEnvelopes(t, ts.canvas["journal"]); envs != 1 {
|
||||
t.Fatalf("exactly one task.queue.enqueue envelope expected, got %d", envs)
|
||||
}
|
||||
|
||||
_, third := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "a different demand", "origin": "consent_brief"}`)
|
||||
id3, _ := third["entry_id"].(string)
|
||||
if id3 == "" || id3 == id1 {
|
||||
t.Fatalf("distinct text must still enqueue a new entry, got %v", third)
|
||||
}
|
||||
store, _ = readQueueStore(ts.canvas["queue"])
|
||||
if len(store.Entries) != 2 {
|
||||
t.Fatalf("distinct text must append: entries=%d", len(store.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskQueueEnqueueDuplicateStaleIsFreshEnqueue: the idempotency scan
|
||||
// is bounded by the same 30-minute freshness gate as the occupancy law —
|
||||
// a stale identical entry no longer dedupes.
|
||||
func TestTaskQueueEnqueueDuplicateStaleIsFreshEnqueue(t *testing.T) {
|
||||
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("the lane stays busy through the queue store")
|
||||
})
|
||||
ts.plant(t, "tasks", awaitingApprovalCheckpoint())
|
||||
old := time.Now().Add(-2 * time.Hour).UTC().Format("2006-01-02T15:04:05.999999+00:00")
|
||||
if !writeQueueStore(ts.canvas["queue"], &queueStore{SchemaVersion: 1, Entries: []queueEntry{
|
||||
{EntryID: "q_old", Text: "build the scheduler", QueuedAt: old, Origin: "consent_brief"},
|
||||
}}) {
|
||||
t.Fatal("plant failed")
|
||||
}
|
||||
|
||||
_, out := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "build the scheduler", "origin": "consent_brief"}`)
|
||||
if out["duplicate"] == true || out["entry_id"] == "q_old" {
|
||||
t.Fatalf("a stale identical entry must not dedupe, got %v", out)
|
||||
}
|
||||
store, _ := readQueueStore(ts.canvas["queue"])
|
||||
if len(store.Entries) != 2 {
|
||||
t.Fatalf("stale duplicate must enqueue fresh: entries=%d", len(store.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskQueueEnqueueDuplicateDifferentOriginIsFreshEnqueue: the
|
||||
// idempotency key is (origin, text) — same text from another origin
|
||||
// still enqueues.
|
||||
func TestTaskQueueEnqueueDuplicateDifferentOriginIsFreshEnqueue(t *testing.T) {
|
||||
ts := newQueueSurface(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("the lane stays busy through the queue store")
|
||||
})
|
||||
ts.plant(t, "tasks", awaitingApprovalCheckpoint())
|
||||
now := time.Now().UTC().Format("2006-01-02T15:04:05.999999+00:00")
|
||||
if !writeQueueStore(ts.canvas["queue"], &queueStore{SchemaVersion: 1, Entries: []queueEntry{
|
||||
{EntryID: "q_live", Text: "build the scheduler", QueuedAt: now, Origin: "task_enter"},
|
||||
}}) {
|
||||
t.Fatal("plant failed")
|
||||
}
|
||||
|
||||
_, out := ts.postJSON(t, "/v1/task-queue/enqueue", `{"text": "build the scheduler", "origin": "consent_brief"}`)
|
||||
if out["duplicate"] == true || out["entry_id"] == "q_live" {
|
||||
t.Fatalf("same text from another origin must not dedupe, got %v", out)
|
||||
}
|
||||
store, _ := readQueueStore(ts.canvas["queue"])
|
||||
if len(store.Entries) != 2 {
|
||||
t.Fatalf("cross-origin enqueue must append: entries=%d", len(store.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// countQueueEnvelopes counts task.queue.enqueue envelopes in a journal.
|
||||
func countQueueEnvelopes(t *testing.T, journalPath string) int {
|
||||
t.Helper()
|
||||
lines, _ := tailJournalLines(journalPath, 0)
|
||||
n := 0
|
||||
for _, l := range lines {
|
||||
if strings.Contains(l, `"task.queue.enqueue"`) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 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 +405,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 +417,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 +485,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 +508,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 +531,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 +554,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 +709,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 +774,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 +857,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 +871,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module rune/skills-engine
|
||||
|
||||
go 1.25.0
|
||||
@@ -0,0 +1,44 @@
|
||||
// main — serve the skills listing surface: skills-engine serve <addr>
|
||||
// <skills-dir> [--static <snapshot.json>]. Default is the live registry
|
||||
// lister; --static serves a labeled snapshot (honest fallback).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
if len(args) < 3 || args[0] != "serve" {
|
||||
fmt.Fprintln(os.Stderr, "usage: skills-engine serve <addr> <skills-dir> [--static <snapshot.json>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
addr, skillsDir := args[1], args[2]
|
||||
var lister SkillLister
|
||||
source := "registry"
|
||||
if len(args) >= 5 && args[3] == "--static" {
|
||||
data, err := os.ReadFile(args[4])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "snapshot: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var entries []RosterEntry
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "snapshot parse: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
lister = &StaticLister{Entries: entries}
|
||||
source = "static"
|
||||
} else {
|
||||
lister = NewRegistryLister(skillsDir)
|
||||
}
|
||||
srv := NewSkillsServer(lister, source)
|
||||
fmt.Fprintf(os.Stderr, "skills-engine (%s) reading on http://%s\n", source, addr)
|
||||
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
// server.go — the skills listing HTTP surface (rune-core runecore
|
||||
// server.go precedent: same error envelope, [OI]-style list shapes).
|
||||
// READ-ONLY: the roster is served; consent/activation writes stay on
|
||||
// the Python engine until parity soak, and the method gate makes a
|
||||
// write verb a 405 rather than an accident.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// SkillsServer is the listing surface over one SkillLister.
|
||||
type SkillsServer struct {
|
||||
lister SkillLister
|
||||
identity string
|
||||
source string // "registry" (live) or "static" (labeled snapshot)
|
||||
}
|
||||
|
||||
// NewSkillsServer builds the surface. source labels the lister
|
||||
// honestly ("registry" | "static") so a snapshot can never pass as the
|
||||
// live feed.
|
||||
func NewSkillsServer(l SkillLister, source string) *SkillsServer {
|
||||
return &SkillsServer{lister: l, identity: "skills-engine", source: source}
|
||||
}
|
||||
|
||||
// Handler mounts every route.
|
||||
func (s *SkillsServer) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
handle := func(path string, h http.HandlerFunc) {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != path {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
errorEnvelope(w, http.StatusMethodNotAllowed, "read_only",
|
||||
"skills-engine serves reads only this wave; writes stay on the Python engine")
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
handle("/v1/skills", s.handleSkills)
|
||||
handle("/v1/skills/status", s.handleStatus)
|
||||
handle("/v1/health", s.handleHealth)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func errorEnvelope(w http.ResponseWriter, status int, code, message string) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"error": map[string]string{"code": code, "message": message},
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
// handleSkills: the roster listing — the same {"object":"list",
|
||||
// "data":[...]} envelope the TUI's other lister consumers read, with
|
||||
// roster entries of the tui/skills Entry shape (id/kind/state) plus the
|
||||
// honest source label. A lister error is a 502 envelope naming the
|
||||
// cause — never a silently empty roster.
|
||||
func (s *SkillsServer) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.lister.List()
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadGateway, "lister_error", err.Error())
|
||||
return
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []RosterEntry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"object": "list",
|
||||
"source": s.source,
|
||||
"count": len(entries),
|
||||
"data": entries,
|
||||
})
|
||||
}
|
||||
|
||||
// handleStatus: surface identity + source truth.
|
||||
func (s *SkillsServer) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := s.lister.List()
|
||||
status := "ready"
|
||||
if err != nil {
|
||||
status = "degraded"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"identity": s.identity,
|
||||
"status": status,
|
||||
"source": s.source,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SkillsServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"identity": s.identity,
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Package main — skills-engine: the Go-side read seam over the skills
|
||||
// registry (retirement-map PORT→INTEGRATE→REMOVE; the Python engine
|
||||
// src/rune/skills_engine.py remains the authoritative WRITE runtime
|
||||
// until parity soak — this surface is read-only).
|
||||
//
|
||||
// The registry lives OUTSIDE the packages (skills law 5, authority
|
||||
// outside packages): <config>/skills/registry.json holds patterns and
|
||||
// capabilities with their state truth; the packages themselves are
|
||||
// guidance-only SKILL.md. This seam reads that registry verbatim — it
|
||||
// never writes, never advances a state, never mints consent.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RosterEntry is one roster line — the same shape the TUI's skills face
|
||||
// consumes (tui/skills Entry: id, kind label, state truth) and the same
|
||||
// shape skills_engine.roster() serves.
|
||||
type RosterEntry struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// SkillLister is the minimal Go seam beside the TUI's other lister
|
||||
// seams (ModelLister precedent): the live source of the skills roster.
|
||||
// Implementations must be safe for concurrent use and must never
|
||||
// mutate the underlying registry.
|
||||
type SkillLister interface {
|
||||
// List returns the full roster (patterns + capabilities), oldest
|
||||
// (lowest id) first. An empty roster is a valid, honest answer.
|
||||
List() ([]RosterEntry, error)
|
||||
}
|
||||
|
||||
// registryFile is the on-disk registry JSON (skills_engine.py
|
||||
// registry() shape): version, seq, patterns{id:{state,...}},
|
||||
// capabilities{id:{kind,state,...}}.
|
||||
type registryFile struct {
|
||||
Version int `json:"version"`
|
||||
Seq int `json:"seq"`
|
||||
Patterns map[string]map[string]any `json:"patterns"`
|
||||
Capabilities map[string]map[string]any `json:"capabilities"`
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// RegistryLister reads the canonical registry.json read-only. This is
|
||||
// the live seam over the Python engine's data: whatever the consent /
|
||||
// activation flow last committed is what the roster shows — no
|
||||
// snapshotting, no caching of stale state between polls.
|
||||
type RegistryLister struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRegistryLister builds a lister over <skills-dir>/registry.json.
|
||||
func NewRegistryLister(skillsDir string) *RegistryLister {
|
||||
return &RegistryLister{path: filepath.Join(skillsDir, "registry.json")}
|
||||
}
|
||||
|
||||
// List reads the registry and projects the roster exactly as
|
||||
// skills_engine.roster() does: patterns first (kind "pattern", their
|
||||
// state), then capabilities (their declared kind and state), ids sorted.
|
||||
// A missing registry is an honest EMPTY roster (the engine has noticed
|
||||
// nothing yet) — never an error, never a fabricated entry.
|
||||
func (l *RegistryLister) List() ([]RosterEntry, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
data, err := os.ReadFile(l.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []RosterEntry{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("registry read: %w", err)
|
||||
}
|
||||
var reg registryFile
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// A corrupt registry is reported honestly — the TUI renders
|
||||
// the error; the seam never guesses roster truth.
|
||||
return nil, fmt.Errorf("registry %s unreadable: %w", l.path, err)
|
||||
}
|
||||
out := make([]RosterEntry, 0, len(reg.Patterns)+len(reg.Capabilities))
|
||||
for id, p := range reg.Patterns {
|
||||
out = append(out, RosterEntry{ID: id, Kind: "pattern", State: str(p["state"])})
|
||||
}
|
||||
for id, c := range reg.Capabilities {
|
||||
kind := str(c["kind"])
|
||||
if kind == "" {
|
||||
kind = "skill"
|
||||
}
|
||||
out = append(out, RosterEntry{ID: id, Kind: kind, State: str(c["state"])})
|
||||
}
|
||||
sortEntries(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StaticLister serves a fixed snapshot. This is the honest fallback the
|
||||
// audit's gap row allows: when the live registry is not the truth
|
||||
// source (a frozen fixture, a lab seed), the seam still serves — and
|
||||
// the snapshot is labeled static at the endpoint, never passed off as
|
||||
// live.
|
||||
type StaticLister struct {
|
||||
Entries []RosterEntry
|
||||
}
|
||||
|
||||
// List returns the snapshot verbatim.
|
||||
func (s *StaticLister) List() ([]RosterEntry, error) {
|
||||
return append([]RosterEntry(nil), s.Entries...), nil
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func sortEntries(in []RosterEntry) {
|
||||
for i := 1; i < len(in); i++ {
|
||||
for j := i; j > 0 && in[j].ID < in[j-1].ID; j-- {
|
||||
in[j], in[j-1] = in[j-1], in[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func regJSON(patterns, caps string) string {
|
||||
return fmtReg(patterns, caps)
|
||||
}
|
||||
|
||||
func fmtReg(patterns, caps string) string {
|
||||
return `{"version":1,"seq":3,"patterns":` + patterns + `,"capabilities":` + caps + `}`
|
||||
}
|
||||
|
||||
func newRegistryServer(t *testing.T, reg string) *httptest.Server {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if reg != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(reg), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ts := httptest.NewServer(NewSkillsServer(NewRegistryLister(dir), "registry").Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
func TestSkillsListLiveRegistry(t *testing.T) {
|
||||
reg := fmtReg(
|
||||
`{"pat-1":{"state":"noticed"},"pat-2":{"state":"declined"}}`,
|
||||
`{"cap-1":{"kind":"skill+automation","state":"active"},"cap-2":{"kind":"automation","state":"tested"}}`)
|
||||
ts := newRegistryServer(t, reg)
|
||||
code, body := get(t, ts.URL+"/v1/skills")
|
||||
if code != http.StatusOK || body["object"] != "list" || body["source"] != "registry" {
|
||||
t.Fatalf("list envelope: %d %v", code, body)
|
||||
}
|
||||
if body["count"].(float64) != 4 {
|
||||
t.Fatalf("count: %v", body)
|
||||
}
|
||||
data := body["data"].([]any)
|
||||
// sorted by id: capabilities first, patterns last
|
||||
first := data[0].(map[string]any)
|
||||
if first["id"] != "cap-1" || first["kind"] != "skill+automation" || first["state"] != "active" {
|
||||
t.Fatalf("roster entry shape: %v", first)
|
||||
}
|
||||
last := data[3].(map[string]any)
|
||||
if last["id"] != "pat-2" || last["kind"] != "pattern" || last["state"] != "declined" {
|
||||
t.Fatalf("pattern entry: %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsListEmptyRegistryIsHonest(t *testing.T) {
|
||||
// no registry file at all: the engine noticed nothing — empty list, 200
|
||||
ts := newRegistryServer(t, "")
|
||||
code, body := get(t, ts.URL+"/v1/skills")
|
||||
if code != http.StatusOK || body["count"].(float64) != 0 {
|
||||
t.Fatalf("missing registry must be an honest empty roster: %d %v", code, body)
|
||||
}
|
||||
if data := body["data"].([]any); len(data) != 0 {
|
||||
t.Fatalf("data must be [] not null: %v", body["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsListCorruptRegistryIsAnError(t *testing.T) {
|
||||
ts := newRegistryServer(t, "{not json")
|
||||
code, body := get(t, ts.URL+"/v1/skills")
|
||||
if code != http.StatusBadGateway || body["error"] == nil {
|
||||
t.Fatalf("corrupt registry must surface, not silently empty: %d %v", code, body)
|
||||
}
|
||||
code, body = get(t, ts.URL+"/v1/skills/status")
|
||||
if code != http.StatusOK || body["status"] != "degraded" {
|
||||
t.Fatalf("status degrades honestly: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsReadsLiveState(t *testing.T) {
|
||||
// the seam must serve what the (Python) write runtime last committed —
|
||||
// a registry change is visible on the next poll, no stale cache
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "registry.json")
|
||||
if err := os.WriteFile(path, []byte(fmtReg(`{}`, `{"cap-1":{"kind":"skill","state":"drafted"}}`)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(NewSkillsServer(NewRegistryLister(dir), "registry").Handler())
|
||||
defer ts.Close()
|
||||
_, body := get(t, ts.URL+"/v1/skills")
|
||||
if body["count"].(float64) != 1 {
|
||||
t.Fatalf("initial roster: %v", body)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(fmtReg(`{}`, `{"cap-1":{"kind":"skill","state":"active"}}`)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, body = get(t, ts.URL+"/v1/skills")
|
||||
entry := body["data"].([]any)[0].(map[string]any)
|
||||
if entry["state"] != "active" {
|
||||
t.Fatalf("stale roster: the activation was not visible on the next poll: %v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticListerIsLabeled(t *testing.T) {
|
||||
ts := httptest.NewServer(NewSkillsServer(&StaticLister{Entries: []RosterEntry{
|
||||
{ID: "pat-1", Kind: "pattern", State: "proposal"},
|
||||
}}, "static").Handler())
|
||||
defer ts.Close()
|
||||
code, body := get(t, ts.URL+"/v1/skills")
|
||||
if code != http.StatusOK || body["source"] != "static" || body["count"].(float64) != 1 {
|
||||
t.Fatalf("static snapshot must carry its honest label: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsReadOnlyGate(t *testing.T) {
|
||||
ts := newRegistryServer(t, fmtReg(`{}`, `{}`))
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||
req, _ := http.NewRequest(method, ts.URL+"/v1/skills", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("%s on the read surface must be 405, got %d", method, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
code, body := get(t, ts.URL+"/nope")
|
||||
if code != http.StatusNotFound || body["error"] == nil {
|
||||
t.Fatalf("unknown route envelope: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListerConcurrentSafe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "registry.json")
|
||||
if err := os.WriteFile(path, []byte(fmtReg(`{"pat-1":{"state":"noticed"}}`, `{}`)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := NewRegistryLister(dir)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := l.List(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.331.0"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -15,7 +15,7 @@ import os
|
||||
import sys
|
||||
|
||||
# Rune Core bootstrap (Option C): the bridge lives in src/rune.
|
||||
_REPO = os.environ.get("BUILDER_BRIDGE_REPO", "/mnt/workdrive/issue161-harness") # #41: env override, default preserved
|
||||
_REPO = os.environ.get("BUILDER_BRIDGE_REPO", "/mnt/workdrive/rune") # #41: env override
|
||||
sys.path.insert(0, os.path.join(_REPO, "src"))
|
||||
sys.path.append(_REPO) # tests/ style imports if ever needed
|
||||
|
||||
@@ -33,6 +33,28 @@ def main() -> int:
|
||||
cap = preflight(task)
|
||||
print(json.dumps(cap.to_dict()))
|
||||
return 0
|
||||
if not sys.stdin.isatty() and not task:
|
||||
# JSON op surface (builder-bridge hook contract): one op object on
|
||||
# stdin, e.g. {"op": "preflight", "text": "..."} — the hook sends
|
||||
# ops, not argv prose.
|
||||
try:
|
||||
payload = json.loads(sys.stdin.read() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
print(json.dumps({"ok": False, "error": "unparseable op payload"}))
|
||||
return 1
|
||||
op = payload.get("op", "preflight")
|
||||
if op != "preflight":
|
||||
print(json.dumps({"ok": False, "error": f"unknown op: {op}"}))
|
||||
return 1
|
||||
from rune.construction_authority import authority
|
||||
cap = preflight(payload.get("text", ""))
|
||||
out = cap.to_dict()
|
||||
auth = authority(payload.get("cwd"))
|
||||
out["ok"] = True
|
||||
out["contract_id"] = auth.get("contract_id")
|
||||
out["authority_active"] = auth.get("active")
|
||||
print(json.dumps(out))
|
||||
return 0
|
||||
cap = preflight(task)
|
||||
print(cap.to_markdown())
|
||||
return 0
|
||||
|
||||
@@ -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.331.0"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -372,11 +372,64 @@ def _semantic_engagement(text, rule):
|
||||
return max(0.0, min(1.0, cos))
|
||||
|
||||
|
||||
# Native engine integration (retirement-conversion row 6, PORT→INTEGRATE):
|
||||
# decide() resolves through the Go engine (src/go/persona-gate, `py-decide`
|
||||
# parity surface — the same decision table the parity harness pins). The
|
||||
# Python implementations stay as the escape hatch
|
||||
# (RUNE_PERSONA_GATE_ENGINE=python); fail-loud, no silent fallback.
|
||||
|
||||
def _pg_go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_PERSONA_GATE_ENGINE", "go") != "python"
|
||||
|
||||
def _pg_go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_PERSONA_GATE_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "persona-gate", "persona-gate")
|
||||
|
||||
def _go_rule_json(r) -> dict:
|
||||
# Go decodeRulesJSON wire shape (includes topic — _rule_json omits it
|
||||
# because the cognition kernel carries it elsewhere).
|
||||
return {
|
||||
"text": r["text"], "topic": r.get("topic", ""),
|
||||
"priority": int(r.get("priority", 0)),
|
||||
"restrictive": bool(r.get("restrictive", False)),
|
||||
"user_rule": bool(r.get("user_rule", False)),
|
||||
"words": sorted(r["words"]),
|
||||
"core": r.get("core", ""),
|
||||
"citation": r.get("citation"),
|
||||
}
|
||||
|
||||
def _go_decide(text, rules):
|
||||
bin_path = _pg_go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"persona-gate go engine binary missing: {bin_path} "
|
||||
"(set RUNE_PERSONA_GATE_ENGINE=python for the legacy path)"
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, "py-decide", (text or "").strip(),
|
||||
"--rules", json.dumps([_go_rule_json(r) for r in rules])],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"persona-gate go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"persona-gate go engine exit {proc.returncode}: {proc.stderr.strip()}"
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
def decide(text, rules=None):
|
||||
"""The persona decides on a request, deterministically (R2 W3.2:
|
||||
Rust kernel when built; the semantic cosines are gathered here —
|
||||
embedder calls are IO — and passed into the kernel)."""
|
||||
Go engine live; Rust kernel and Python oracle retained below)."""
|
||||
rules = rules if rules is not None else load_rules()
|
||||
if _pg_go_engine_enabled():
|
||||
return _go_decide(text, rules)
|
||||
if os.environ.get("RUNE_PERSONA_GATE_ENGINE") == "python":
|
||||
return _py_decide(text, rules)
|
||||
core = _cc_pg_core()
|
||||
if core is not None:
|
||||
text_s = (text or "").strip()
|
||||
|
||||
+16
-4
@@ -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.331.0
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -114,6 +114,18 @@ except ImportError:
|
||||
BACKEND_AVAILABLE = False
|
||||
|
||||
|
||||
def fold_lane_result(result):
|
||||
"""Fold one parallel-loader lane result: (key, value) or (key, value,
|
||||
extra). The old inline `key, value = result` unpack raised ValueError
|
||||
on the tasks lane's 3-tuple ("tasks", active, pins), so the tasks
|
||||
branch never ran."""
|
||||
if isinstance(result, tuple) and result:
|
||||
key, value = result[0], result[1]
|
||||
extra = result[2] if len(result) > 2 else None
|
||||
return key, value, extra
|
||||
return None, None, None
|
||||
|
||||
|
||||
def collect_post_response_signals(response: str, messages_count: int) -> list[str]:
|
||||
"""Gather the operational signals that used to each print a dim
|
||||
system message after every task/build response (the dim-spam source,
|
||||
@@ -1901,7 +1913,7 @@ class RuneApp(App):
|
||||
# Process results on main thread (safe for widget mutation)
|
||||
for result in results:
|
||||
if isinstance(result, tuple):
|
||||
key, value = result
|
||||
key, value, _extra = fold_lane_result(result)
|
||||
if key == "persona" and value:
|
||||
self.chat_channel.add_message("system", "[dim]✓ Persona compiled[/dim]")
|
||||
elif key == "persona_error":
|
||||
@@ -1916,7 +1928,7 @@ class RuneApp(App):
|
||||
lines = [f"[dim]{len(active)} active tasks:[/dim]"]
|
||||
for t in active[:5]:
|
||||
status = t.get("status", "?")
|
||||
summary = t.get("summary", "")[:60]
|
||||
summary = (t.get("summary") or t.get("title") or "")[:60]
|
||||
lines.append(f"[dim] [{status}] {summary}[/dim]")
|
||||
self.task_channel.add_message("system", "\n".join(lines))
|
||||
else:
|
||||
@@ -2035,7 +2047,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.331.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -37,6 +37,8 @@ an effect. Zero/acceptable delta gates the NEXT stage.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
@@ -184,6 +186,42 @@ def execute_stage(stage: StageContract, action) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
# Native engine integration (retirement-conversion row 6, PORT→INTEGRATE):
|
||||
# the deterministic kernels (liveness_canary, reconcile) resolve through the
|
||||
# Go engine (src/go/stage-guards) — parity-ported tables. The Python
|
||||
# implementations stay as the escape hatch (RUNE_STAGE_GUARDS_ENGINE=
|
||||
# python); fail-loud, no silent fallback.
|
||||
|
||||
def _sg_go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_STAGE_GUARDS_ENGINE", "go") != "python"
|
||||
|
||||
def _sg_go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_STAGE_GUARDS_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "stage-guards", "stage-guards")
|
||||
|
||||
def _sg_go_eval(cmd: str, args: list) -> dict:
|
||||
bin_path = _sg_go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"stage-guards go engine binary missing: {bin_path} "
|
||||
"(set RUNE_STAGE_GUARDS_ENGINE=python for the legacy path)"
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, cmd, *args], capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"stage-guards go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"stage-guards go engine exit {proc.returncode}: {proc.stderr.strip()}"
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def liveness_canary(last_progress_at: float, now: float | None = None,
|
||||
stall_limit_secs: float = 300.0) -> dict:
|
||||
"""§15F: is progress still possible? Inactivity NEVER means
|
||||
@@ -192,6 +230,10 @@ def liveness_canary(last_progress_at: float, now: float | None = None,
|
||||
Dichotomy: deterministic kernel delegates to cognition_core
|
||||
(cc_liveness_canary); Python fallback is the parity floor."""
|
||||
now = now if now is not None else time.monotonic()
|
||||
if _sg_go_engine_enabled():
|
||||
return _sg_go_eval("liveness", [repr(float(last_progress_at)),
|
||||
repr(float(now)),
|
||||
repr(float(stall_limit_secs))])
|
||||
try:
|
||||
from rune import cc_loader as _cl
|
||||
if _cl.available():
|
||||
@@ -284,6 +326,11 @@ def reconcile(desired: dict, read_actual, *, stage_id: str = "",
|
||||
stage MUST NOT start on a non-empty delta."""
|
||||
tolerance = tolerance or set()
|
||||
actual = read_actual()
|
||||
if _sg_go_engine_enabled():
|
||||
return _sg_go_eval("reconcile", [json.dumps(desired),
|
||||
json.dumps(actual),
|
||||
stage_id,
|
||||
json.dumps(sorted(tolerance))])
|
||||
try:
|
||||
from rune import cc_loader as _cl
|
||||
if _cl.available():
|
||||
|
||||
@@ -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
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-21T11:06:43Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-21T11:07:04Z",
|
||||
"evidence": "user:50-scale distribution 2026-10-20",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-21T11:07:25Z",
|
||||
"evidence": "builder:wave-builder-bridge-rune-retarget",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-builder-bridge-rune-retarget"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-builder-bridge-rune-retarget",
|
||||
"evidence": [
|
||||
"affected surface known: ['config/mimo-hooks/builder-bridge.ts', 'scripts/install_mimo_hook.py']",
|
||||
"insufficient lexical evidence either way"
|
||||
],
|
||||
"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": [
|
||||
"config/mimo-hooks/builder-bridge.ts",
|
||||
"scripts/install_mimo_hook.py",
|
||||
"tests/test_builder_bridge_hook_retarget.py",
|
||||
"tests/test_hook_parity.py"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"builder-bridge.ts BRIDGE resolves to /mnt/workdrive/rune/src/rune/builder_bridge_cli.py",
|
||||
"builder-bridge.ts PY_CANDIDATES include the rune .test-venv interpreter",
|
||||
"installed hook at ~/.mimocode/hooks/builder-bridge.ts hash-matches canonical",
|
||||
"no stale issue161-harness path remains in any mimo-hooks constant"
|
||||
],
|
||||
"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": [
|
||||
"config/mimo-hooks/builder-bridge.ts",
|
||||
"scripts/install_mimo_hook.py",
|
||||
"tests/test_builder_bridge_hook_retarget.py",
|
||||
"tests/test_hook_parity.py"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-builder-bridge-rune-retarget",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "config/mimo-hooks/builder-bridge.ts"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/install_mimo_hook.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tests/test_builder_bridge_hook_retarget.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tests/test_hook_parity.py"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": []
|
||||
},
|
||||
"id": "contract:ADMIT-wave-builder-bridge-rune-retarget",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: retarget config/mimo-hooks/builder-bridge.ts from the stale issue161-harness anchor to the rune repo (src/rune/builder_b"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"test:builder_bridge_hook_retarget",
|
||||
"test:hook_parity",
|
||||
"tests/test_builder_bridge_hook_retarget.py (red-first retarget corpus)"
|
||||
],
|
||||
"gap_evidence": "insufficient lexical evidence either way; affected surface known: ['config/mimo-hooks/builder-bridge.ts', 'scripts/install_mimo_hook.py']",
|
||||
"migration_obligations": [],
|
||||
"need": "retarget config/mimo-hooks/builder-bridge.ts from the stale issue161-harness anchor to the rune repo (src/rune/builder_bridge_cli.py and the rune venv interpreter candidates), following the scaffolding-guard.ts retarget pattern; verify the bridge's function against the rune construction authority; keep the hash-verified install discipline (scripts/install_mimo_hook.py); sweep ALL hook constants for other stale harness paths. Red-first on the bridge's rune behavior.",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
"config/mimo-hooks/builder-bridge.ts",
|
||||
"config/mimo-hooks/scaffolding-guard.ts",
|
||||
"scripts/admit_wave_factory_authority_port.py",
|
||||
"src/rune/builder_bridge_cli.py"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"config/mimo-hooks/builder-bridge.ts",
|
||||
"scripts/install_mimo_hook.py",
|
||||
"tests/test_builder_bridge_hook_retarget.py",
|
||||
"tests/test_hook_parity.py"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"builder_bridge_cli.py end-to-end: preflight + construction authority status/check respond with rune contract data",
|
||||
"grep sweep: no issue161-harness reference left in config/mimo-hooks constants",
|
||||
"pytest tests/test_builder_bridge.py tests/test_hook_parity.py green",
|
||||
"pytest tests/test_builder_bridge_hook_retarget.py green (red-first)",
|
||||
"pytest tests/test_builder_bridge_hook_retarget.py",
|
||||
"pytest tests/test_hook_parity.py",
|
||||
"python3 scripts/install_mimo_hook.py syncs install; parity hash match verified"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-21T11:07:25Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-21T11:15:32Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-21T11:15:59Z",
|
||||
"evidence": "user:50-scale distribution 2026-10-20",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-21T11:16:25Z",
|
||||
"evidence": "builder:wave-ci-e2e-rig",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-ci-e2e-rig"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-ci-e2e-rig",
|
||||
"evidence": [
|
||||
"affected surface known: ['scripts/ci_e2e_rig.sh', 'scripts/admit_wave_ci_e2e_rig.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": [
|
||||
"docs/ci-e2e-rig.md",
|
||||
"scripts/admit_wave_ci_e2e_rig.py",
|
||||
"scripts/ci_e2e_rig.sh",
|
||||
"tests/test_ci_e2e_rig.py",
|
||||
"tests/test_workflow_script_contract.py"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"scripts/ci_e2e_rig.sh registers a scratch Gitea runner, creates a scratch repo, runs a workflow end-to-end, verifies outputs, and tears everything down",
|
||||
"the workflow script contract tests cover auto-version, tui-release, and maintenance: SIGPIPE pipes and missing tag fallbacks go RED at test time"
|
||||
],
|
||||
"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": [
|
||||
"docs/ci-e2e-rig.md",
|
||||
"scripts/admit_wave_ci_e2e_rig.py",
|
||||
"scripts/ci_e2e_rig.sh",
|
||||
"tests/test_ci_e2e_rig.py",
|
||||
"tests/test_workflow_script_contract.py"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-ci-e2e-rig",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "docs/ci-e2e-rig.md"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/admit_wave_ci_e2e_rig.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/ci_e2e_rig.sh"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tests/test_ci_e2e_rig.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tests/test_workflow_script_contract.py"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": []
|
||||
},
|
||||
"id": "contract:ADMIT-wave-ci-e2e-rig",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: promote the release deep-dive's tag-repair proof rig (temporary second runner + scratch repo) into a permanent, reusable"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"scripts/ci_e2e_rig.sh (scratch-runner + scratch-repo e2e proof rig, teardown-safe)",
|
||||
"test:ci_e2e_rig",
|
||||
"test:workflow_script_contract",
|
||||
"tests/test_ci_e2e_rig.py (rig contract tests, non-live)"
|
||||
],
|
||||
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['scripts/ci_e2e_rig.sh', 'scripts/admit_wave_ci_e2e_rig.py']",
|
||||
"migration_obligations": [],
|
||||
"need": "promote the release deep-dive's tag-repair proof rig (temporary second runner + scratch repo) into a permanent, reusable CI proof rig: scripts/ci_e2e_rig.sh registers a scratch runner, creates a scratch repo, runs a workflow end-to-end, verifies outputs, and tears down \u2014 documented and tested \u2014 plus tests/test_workflow_script_contract.py extended to cover the three pipelines (auto-version, tui-release, maintenance) so any future workflow edit reintroducing the SIGPIPE pipe or dropping the GITEA_REF_NAME tag fallback goes RED at test time, not in production (red-first)",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
".gitea/workflows/auto-version.yml",
|
||||
".gitea/workflows/maintenance.yml",
|
||||
".gitea/workflows/tui-release.yml",
|
||||
"scripts/tag_repair.py",
|
||||
"tests/test_workflow_script_contract.py"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"docs/ci-e2e-rig.md",
|
||||
"scripts/admit_wave_ci_e2e_rig.py",
|
||||
"scripts/ci_e2e_rig.sh",
|
||||
"tests/test_ci_e2e_rig.py",
|
||||
"tests/test_workflow_script_contract.py"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"bash -n clean on scripts/ci_e2e_rig.sh; no live gitea launch during the gaming session",
|
||||
"pytest tests/test_ci_e2e_rig.py",
|
||||
"pytest tests/test_workflow_script_contract.py",
|
||||
"tests/test_ci_e2e_rig.py green (rig syntax, dry-run mode, teardown-on-failure contract \u2014 nothing live launched)",
|
||||
"tests/test_workflow_script_contract.py green with the three pipelines covered and the new SIGPIPE/tag-fallback guards provably red-first (mutation spot-check)"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-21T11:16:25Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-21T10:42:20Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-21T10:42:36Z",
|
||||
"evidence": "user:50-scale distribution 2026-10-20",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-21T10:42:53Z",
|
||||
"evidence": "builder:wave-retirement-conversion-change-trust",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-retirement-conversion-change-trust"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-retirement-conversion-change-trust",
|
||||
"evidence": [
|
||||
"affected surface known: ['src/rune/persona_gate.py', 'src/rune/stage_guards.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": [
|
||||
"src/rune/persona_gate.py",
|
||||
"src/rune/stage_guards.py"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"live persona_gate.decide calls resolve through the Go engine (src/go/persona-gate) with the Python path retained as escape hatch (RUNE_PERSONA_GATE_ENGINE=python)",
|
||||
"live stage_guards stage checks resolve through the Go engine (src/go/stage-guards) with the Python path retained as escape hatch (RUNE_STAGE_GUARDS_ENGINE=python)",
|
||||
"row 5 recorded as no-Python-oracle (integration N/A); no Python oracle deleted this wave"
|
||||
],
|
||||
"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": [
|
||||
"src/rune/persona_gate.py",
|
||||
"src/rune/stage_guards.py",
|
||||
"verification/integrated/file-change-safety-integrated.json",
|
||||
"verification/integrated/trust-and-sovereignty-integrated.json"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-retirement-conversion-change-trust",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/rune/persona_gate.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/rune/stage_guards.py"
|
||||
},
|
||||
{
|
||||
"kind": "workflow_config",
|
||||
"reason": "kind workflow_config has no deterministic resolver",
|
||||
"surface": "verification/integrated/file-change-safety-integrated.json"
|
||||
},
|
||||
{
|
||||
"kind": "workflow_config",
|
||||
"reason": "kind workflow_config has no deterministic resolver",
|
||||
"surface": "verification/integrated/trust-and-sovereignty-integrated.json"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": [
|
||||
"verification/integrated/file-change-safety-integrated.json",
|
||||
"verification/integrated/trust-and-sovereignty-integrated.json"
|
||||
]
|
||||
},
|
||||
"id": "contract:ADMIT-wave-retirement-conversion-change-trust",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: retirement-conversion wave 2 (rows 5 file-change-safety + 6 trust-and-sovereignty), PORT \u2192 INTEGRATE \u2192 REMOVE: row 6 rep"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"verification/integrated/file-change-safety-integrated.json",
|
||||
"verification/integrated/trust-and-sovereignty-integrated.json"
|
||||
],
|
||||
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['src/rune/persona_gate.py', 'src/rune/stage_guards.py']",
|
||||
"migration_obligations": [],
|
||||
"need": "retirement-conversion wave 2 (rows 5 file-change-safety + 6 trust-and-sovereignty), PORT \u2192 INTEGRATE \u2192 REMOVE: row 6 repoints the live Python consumers of persona_gate.decide and stage_guards checks to the Go engine surfaces (src/go/persona-gate, src/go/stage-guards) via in-module delegation facades with fail-loud semantics and deliberate opt-out env vars, runs the focused batteries integrated, and records per-consumer integrated-run receipts; row 5 records the honest finding that no Python oracle exists (domains.json src_rune: []) \u2014 the Go restore-points engine is net-new, integration is N/A, and the wave verifies the Go side (tests -race, tui/changes, tui/history). NO Python deletion this wave (integration proves live first; removal is a separate wave per the owner's amended rule)",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
"docs/factory/domains.json",
|
||||
"docs/retirement-map.md",
|
||||
"src/go/persona-gate/truth.json",
|
||||
"src/go/stage-guards/truth.json",
|
||||
"verification/integrated/model-routing-integrated.json"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"src/rune/persona_gate.py",
|
||||
"src/rune/stage_guards.py",
|
||||
"verification/integrated/file-change-safety-integrated.json",
|
||||
"verification/integrated/trust-and-sovereignty-integrated.json"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"focused persona_gate/stage_guards/construction_interface batteries green with the Go engines live",
|
||||
"integrated-run receipts recorded under verification/integrated/",
|
||||
"src/go/persona-gate + src/go/stage-guards truth batteries green with -race"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-21T10:42:53Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-21T08:02:02Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-21T08:02:19Z",
|
||||
"evidence": "user:50-scale distribution 2026-10-20",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-21T08:02:35Z",
|
||||
"evidence": "builder:wave-test-vitality-py",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-test-vitality-py"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-test-vitality-py",
|
||||
"evidence": [
|
||||
"affected surface known: ['scripts/admit_wave_test_vitality_py.py', 'tests/']",
|
||||
"insufficient lexical evidence either way"
|
||||
],
|
||||
"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": [
|
||||
"reports/test-vitality-audit-py-2026-10-20.md",
|
||||
"scripts/admit_wave_test_vitality_py.py",
|
||||
"tests/"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"ceremony tests converted into behavior/invariant proofs, each red-first with a mutation spot-check recorded",
|
||||
"genuinely dead tests deleted with per-test rationale",
|
||||
"suite green on the spot-checked packages after conversions"
|
||||
],
|
||||
"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": [
|
||||
"reports/test-vitality-audit-py-2026-10-20.md",
|
||||
"scripts/admit_wave_test_vitality_py.py",
|
||||
"tests/"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-test-vitality-py",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "reports/test-vitality-audit-py-2026-10-20.md"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/admit_wave_test_vitality_py.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tests/"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": []
|
||||
},
|
||||
"id": "contract:ADMIT-wave-test-vitality-py",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: test-vitality sweep phase 2 on the src/rune Python suite (3,702 tests): spot-check methodology from the first audit \u2014 he"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"converted/deleted test bodies in tests/ (red-first, uncommitted)",
|
||||
"reports/test-vitality-audit-py-2026-10-20.md (audit + report)",
|
||||
"scripts/admit_wave_test_vitality_py.py (admission dispatch)"
|
||||
],
|
||||
"gap_evidence": "insufficient lexical evidence either way; affected surface known: ['scripts/admit_wave_test_vitality_py.py', 'tests/']",
|
||||
"migration_obligations": [],
|
||||
"need": "test-vitality sweep phase 2 on the src/rune Python suite (3,702 tests): spot-check methodology from the first audit \u2014 heuristic pattern sweeps (panic-only, implementation-trivia pins, fixed-value-on-random assertions, live-machine-state fixtures), mutation spot-checks on suspicious tests. Classify into vitality classes, convert ceremony candidates red-first, kill genuinely dead tests honestly, report the audit table",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
"AGENTS.md",
|
||||
"reports/test-vitality-audit-2026-10-19.md",
|
||||
"src/rune/",
|
||||
"tests/"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"reports/test-vitality-audit-py-2026-10-20.md",
|
||||
"scripts/admit_wave_test_vitality_py.py",
|
||||
"tests/"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"every conversion carries a mutation spot-check proof",
|
||||
"python -m pytest tests/ green on spot-checked packages after conversions"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-21T08:02:35Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"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"}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""tests/test_builder_bridge_hook_retarget.py — red-first retarget corpus.
|
||||
|
||||
The builder-bridge hook must anchor its constants at the rune repo, not the
|
||||
retired issue161-harness (the scaffolding-guard.ts retarget pattern). These
|
||||
tests fail while any stale harness path remains.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
HOOK = REPO / "config" / "mimo-hooks" / "builder-bridge.ts"
|
||||
SOURCE = HOOK.read_text()
|
||||
|
||||
|
||||
def test_bridge_constant_points_at_rune_repo():
|
||||
m = re.search(r'const BRIDGE = "([^"]+)"', SOURCE)
|
||||
assert m, "BRIDGE constant not found"
|
||||
assert m.group(1) == str(REPO / "src" / "rune" / "builder_bridge_cli.py"), (
|
||||
f"stale harness anchor: {m.group(1)}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_stale_harness_paths_in_hook_constants():
|
||||
assert "issue161-harness" not in SOURCE, (
|
||||
"builder-bridge.ts still references the retired issue161-harness"
|
||||
)
|
||||
|
||||
|
||||
def test_py_candidates_include_rune_venv():
|
||||
assert f'"{REPO}/.test-venv/bin/python"' in SOURCE, (
|
||||
"PY_CANDIDATES must include the rune repo venv interpreter"
|
||||
)
|
||||
|
||||
|
||||
def test_hook_parity_manifest_managed_set():
|
||||
parity = (REPO / "tests" / "test_hook_parity.py").read_text()
|
||||
m = re.search(r"MANAGED = \[[^\]]*\]", parity)
|
||||
assert m and "builder-bridge.ts" in m.group(0) and "scaffolding-guard.ts" in m.group(0), (
|
||||
"hook parity managed set must cover builder-bridge.ts and scaffolding-guard.ts"
|
||||
)
|
||||
|
||||
|
||||
def _cli(script: str, payload: dict) -> dict:
|
||||
import os
|
||||
env = dict(os.environ)
|
||||
# conftest isolates the construction store for in-process tests; the
|
||||
# bridge CLI must see the REAL rune store (that is what it verifies).
|
||||
env.pop("RUNE_CONSTRUCTION_STORE", None)
|
||||
out = subprocess.run(
|
||||
[sys.executable, str(REPO / "src" / "rune" / script)],
|
||||
input=json.dumps(payload), capture_output=True, text=True, timeout=60,
|
||||
env=env,
|
||||
)
|
||||
assert out.returncode == 0, out.stderr[-400:]
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_builder_bridge_cli_resolves_active_rune_contract():
|
||||
env = _cli("builder_bridge_cli.py", {"op": "preflight", "text": "resume building the wave"})
|
||||
assert env.get("ok") is True, env
|
||||
assert env.get("contract_id"), f"no active rune contract projected: {env.get('contract_id')}"
|
||||
|
||||
|
||||
def test_construction_authority_cli_reports_rune_contract():
|
||||
env = _cli("construction_authority_cli.py", {"op": "status", "cwd": str(REPO)})
|
||||
assert env.get("ok") is True, env
|
||||
assert env.get("authority", {}).get("active") is True, env.get("authority")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""test_ci_e2e_rig.py — contract tests for scripts/ci_e2e_rig.sh.
|
||||
|
||||
Non-live by contract: the gaming-session law forbids launching Gitea from
|
||||
tests. These pin the rig's syntax, its dry-run mode (plan printed, nothing
|
||||
launched), the teardown-on-failure trap, scratch-only scoping, and loud
|
||||
failure on missing live credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RIG = os.path.join(ROOT, "scripts", "ci_e2e_rig.sh")
|
||||
|
||||
|
||||
def _run(*args, env=None):
|
||||
e = dict(os.environ)
|
||||
if env:
|
||||
e.update(env)
|
||||
return subprocess.run(
|
||||
["bash", RIG, *args], capture_output=True, text=True, env=e, timeout=30)
|
||||
|
||||
|
||||
def test_rig_is_valid_bash():
|
||||
r = subprocess.run(["bash", "-n", RIG], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
|
||||
def test_rig_dry_run_prints_plan_and_launches_nothing():
|
||||
r = _run("--dry-run")
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "scratch runner" in r.stdout
|
||||
assert "scratch repo" in r.stdout
|
||||
assert "teardown" in r.stdout
|
||||
# dry-run must not demand live credentials
|
||||
assert "required in live mode" not in r.stdout + r.stderr
|
||||
|
||||
|
||||
def test_rig_teardown_is_trap_guaranteed():
|
||||
src = open(RIG).read()
|
||||
assert "trap teardown EXIT" in src, "teardown must be EXIT-trap guaranteed"
|
||||
# the trap body must remove the scratch dir and clean rig resources
|
||||
assert "rm -rf" in src
|
||||
assert "repos/rig-admin" in src or "repos/" in src
|
||||
assert "admin/runners" in src
|
||||
|
||||
|
||||
def test_rig_is_scratch_only():
|
||||
src = open(RIG).read()
|
||||
assert "/api/v1/user/repos" in src, "repo creation must target the rig user's namespace"
|
||||
assert "rig-scratch" in src, "runner/repo names must be scratch-marked"
|
||||
# never a hardwired production repo
|
||||
assert "workdrive/rune.git" not in src
|
||||
|
||||
|
||||
def test_rig_fails_loud_without_live_credentials():
|
||||
r = _run(env={"RUNE_GITEA_URL": "", "RUNE_GITEA_TOKEN": "",
|
||||
"PATH": os.environ["PATH"]})
|
||||
assert r.returncode != 0
|
||||
assert "RUNE_GITEA_URL required in live mode" in r.stderr
|
||||
@@ -0,0 +1,136 @@
|
||||
"""scripts/dedupe_policy.py — consumer-contract tests (red-first).
|
||||
|
||||
memory_store.py's `add` CLI gates through `dedupe_policy.apply`; the
|
||||
module was ABSENT from this checkout (retirement-map row 24: only
|
||||
--no-dedupe worked). These tests pin the consumer's contract:
|
||||
|
||||
apply(db, text, topic, importance, source, confidence)
|
||||
-> {"decision": created|duplicate|superseded, "id", "prior_id",
|
||||
"receipt": {"reason", ...}}
|
||||
|
||||
with the 2026-08-13 policy: one record per data point; exact or similar
|
||||
text with no new info -> duplicate (touch); similar text carrying new
|
||||
info (longer / stronger) -> supersede; every decision receipted.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
SRC_RUNE = ROOT / "src" / "rune"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dp(tmp_path, monkeypatch):
|
||||
"""The dedupe_policy module with a real (tmp) hybrid store behind it."""
|
||||
for p in (str(SCRIPTS), str(SRC_RUNE)):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
monkeypatch.setenv("RUNE_STORE_DB", str(tmp_path / "memory.db"))
|
||||
monkeypatch.setenv("RUNE_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path / "memory"))
|
||||
monkeypatch.setenv("RUNE_CONFIG_DIR", str(tmp_path / "config"))
|
||||
if "dedupe_policy" in sys.modules:
|
||||
del sys.modules["dedupe_policy"]
|
||||
if "memory_store" in sys.modules:
|
||||
del sys.modules["memory_store"]
|
||||
mod = importlib.import_module("dedupe_policy")
|
||||
import memory_store as ms
|
||||
|
||||
db = ms.connect(str(tmp_path / "memory.db"))
|
||||
# Deterministic similarity: Jaccard over normalized tokens (no embedder
|
||||
# in tests — the gate must work on its lexical fallback).
|
||||
monkeypatch.setattr(mod, "_embed_pair", mod._embed_pair, raising=True)
|
||||
yield mod, db
|
||||
db.close()
|
||||
|
||||
|
||||
def _jaccard(dp):
|
||||
import re as _re
|
||||
|
||||
def _pair(a, b):
|
||||
def norm(t):
|
||||
t = _re.sub(r"[^a-z0-9 ]", " ", t.lower())
|
||||
return set(_re.sub(r"\s+", " ", t).strip().split())
|
||||
|
||||
ta, tb = norm(a), norm(b)
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
return len(ta & tb) / len(ta | tb)
|
||||
|
||||
return _pair
|
||||
|
||||
|
||||
def test_module_exists_and_satisfies_consumer_contract(dp):
|
||||
"""The consumer's import (`from dedupe_policy import apply as
|
||||
dedupe_apply`) resolves and apply() returns the receipted shape."""
|
||||
mod, db = dp
|
||||
assert hasattr(mod, "apply")
|
||||
res = mod.apply(db, "the deploy canary gates promotion at 1% error", "ops")
|
||||
assert res["decision"] == "created"
|
||||
assert res["id"]
|
||||
assert "reason" in res["receipt"]
|
||||
|
||||
|
||||
def test_exact_repeat_is_duplicate_not_second_record(dp):
|
||||
mod, db = dp
|
||||
text = "the deploy canary gates promotion at 1% error"
|
||||
first = mod.apply(db, text, "ops")
|
||||
second = mod.apply(db, text, "ops")
|
||||
assert first["decision"] == "created"
|
||||
assert second["decision"] == "duplicate"
|
||||
assert second["id"] == first["id"], "duplicate must touch, never re-insert"
|
||||
n = db.execute(
|
||||
"SELECT COUNT(*) c FROM entries WHERE text=?", (text,)
|
||||
).fetchone()
|
||||
assert n["c"] == 1, "one record per data point"
|
||||
|
||||
|
||||
def test_similar_without_new_info_is_duplicate(dp):
|
||||
mod, db = dp
|
||||
mod._embed_pair = _jaccard(mod)
|
||||
a = "the deploy canary gates promotion at one percent error"
|
||||
b = "the deploy canary gates promotion at one percent error rate"
|
||||
first = mod.apply(db, a, "ops", confidence=0.5)
|
||||
second = mod.apply(db, b, "ops", confidence=0.5)
|
||||
assert first["decision"] == "created"
|
||||
assert second["decision"] == "duplicate"
|
||||
|
||||
|
||||
def test_similar_with_new_info_supersedes(dp):
|
||||
mod, db = dp
|
||||
mod._embed_pair = _jaccard(mod)
|
||||
old = "the deploy canary gates promotion at one percent error"
|
||||
new = "the deploy canary gates promotion at one percent error rate confirmed"
|
||||
first = mod.apply(db, old, "ops", confidence=0.4)
|
||||
second = mod.apply(db, new, "ops", confidence=0.4)
|
||||
assert first["decision"] == "created"
|
||||
assert second["decision"] in ("duplicate", "superseded")
|
||||
# the stronger-source supersede: same data point, meaningfully higher
|
||||
# confidence replaces the old record
|
||||
third = mod.apply(db, old, "ops", confidence=0.9)
|
||||
assert third["decision"] == "superseded"
|
||||
assert third["prior_id"] in (first["id"], second["id"])
|
||||
prior_id = third["prior_id"]
|
||||
prior = db.execute("SELECT status FROM entries WHERE id=?", (prior_id,)).fetchone()
|
||||
assert prior["status"] == "superseded", "old row retired, never deleted"
|
||||
|
||||
|
||||
def test_every_decision_is_receipted(dp):
|
||||
mod, db = dp
|
||||
mod._embed_pair = _jaccard(mod)
|
||||
mod.apply(db, "alpha fact about the indexer", "ops")
|
||||
mod.apply(db, "alpha fact about the indexer", "ops")
|
||||
rows = db.execute("SELECT decision FROM dedupe_receipts ORDER BY id").fetchall()
|
||||
decisions = [r["decision"] for r in rows]
|
||||
assert decisions == ["created", "duplicate"]
|
||||
|
||||
|
||||
def test_empty_text_is_rejected(dp):
|
||||
mod, db = dp
|
||||
assert mod.apply(db, " ", "ops")["decision"] == "rejected"
|
||||
@@ -248,39 +248,45 @@ def _base_event(**over):
|
||||
def test_rejects_unknown_event_kind():
|
||||
import jsonschema
|
||||
schema = _load(SCHEMA)
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="is not one of"):
|
||||
_validate(schema, _base_event(event="progress_note"))
|
||||
|
||||
|
||||
def test_rejects_missing_or_malformed_event_id():
|
||||
import jsonschema
|
||||
schema = _load(SCHEMA)
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="does not match"):
|
||||
_validate(schema, _base_event(event_id="not-an-evt-id"))
|
||||
bad = _base_event()
|
||||
del bad["event_id"]
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="is a required property"):
|
||||
_validate(schema, bad)
|
||||
|
||||
|
||||
def test_rejects_non_z_timestamp():
|
||||
import jsonschema
|
||||
schema = _load(SCHEMA)
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="does not match"):
|
||||
_validate(schema, _base_event(ts="2026-10-17 07:00:00"))
|
||||
|
||||
|
||||
def test_rejects_unknown_channel():
|
||||
import jsonschema
|
||||
schema = _load(SCHEMA)
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="is not one of"):
|
||||
_validate(schema, _base_event(channel="ui"))
|
||||
|
||||
|
||||
def test_rejects_tool_result_without_adapter_keys():
|
||||
import jsonschema
|
||||
schema = _load(SCHEMA)
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="is a required property"):
|
||||
_validate(schema, _base_event(
|
||||
event="tool_result", channel="tool",
|
||||
payload={"name": "terminal"})) # no channel/call_id
|
||||
@@ -289,7 +295,8 @@ def test_rejects_tool_result_without_adapter_keys():
|
||||
def test_rejects_tool_event_on_wrong_channel():
|
||||
import jsonschema
|
||||
schema = _load(SCHEMA)
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="'tool' was expected"):
|
||||
_validate(schema, _base_event(
|
||||
event="tool_action", channel="conversation",
|
||||
payload={"channel": "shell", "call_id": "c1", "name": "bash"}))
|
||||
@@ -298,7 +305,8 @@ def test_rejects_tool_event_on_wrong_channel():
|
||||
def test_rejects_lane_state_outside_enum():
|
||||
import jsonschema
|
||||
schema = _load(SCHEMA)
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="is not one of"):
|
||||
_validate(schema, _base_event(
|
||||
event="lane_state", payload={"lane": "task", "state": "vibing"}))
|
||||
|
||||
@@ -306,7 +314,8 @@ def test_rejects_lane_state_outside_enum():
|
||||
def test_rejects_bad_payload_hash():
|
||||
import jsonschema
|
||||
schema = _load(SCHEMA)
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
with pytest.raises(jsonschema.ValidationError,
|
||||
match="does not match"):
|
||||
_validate(schema, _base_event(payload_hash="md5:zz"))
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ REPO = Path(__file__).resolve().parent.parent
|
||||
CANONICAL_DIR = REPO / "config" / "mimo-hooks"
|
||||
# T22: the managed set is the installer's authority — a hook that reaches the
|
||||
# builder must be version-controlled and drift-checked like any other.
|
||||
MANAGED = ["event-wait-guard.ts", "builder-bridge.ts"]
|
||||
MANAGED = ["event-wait-guard.ts", "builder-bridge.ts", "scaffolding-guard.ts"]
|
||||
# T21.3: the hook lives at the ACTUAL controller user's home, not the
|
||||
# test runner's. Use an env override or skip when unavailable.
|
||||
import os as _os
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from rune.orchestration.atomic import AtomicStore
|
||||
from rune.orchestration.outbox import CorruptOutboxError, OutboxStore
|
||||
from rune.orchestration.records import OutboxRecord
|
||||
@@ -74,11 +76,8 @@ def test_mark_replaces_without_duplication(tmp_path: Path):
|
||||
|
||||
def test_load_invalid_json_raises_corrupt(tmp_path: Path):
|
||||
AtomicStore(tmp_path / "outbox" / "state").write("outbox.json", "{bad json")
|
||||
try:
|
||||
with pytest.raises(CorruptOutboxError, match="outbox corrupt"):
|
||||
OutboxStore(tmp_path / "outbox")
|
||||
raise AssertionError("expected CorruptOutboxError")
|
||||
except CorruptOutboxError:
|
||||
pass
|
||||
|
||||
|
||||
def test_load_wrong_typed_state_raises_corrupt(tmp_path: Path):
|
||||
@@ -95,11 +94,8 @@ def test_load_wrong_typed_state_raises_corrupt(tmp_path: Path):
|
||||
]
|
||||
),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(CorruptOutboxError, match="outbox corrupt"):
|
||||
OutboxStore(tmp_path / "outbox")
|
||||
raise AssertionError("expected CorruptOutboxError")
|
||||
except CorruptOutboxError:
|
||||
pass
|
||||
|
||||
|
||||
def test_load_wrong_typed_ordering_key_raises_corrupt(tmp_path: Path):
|
||||
@@ -116,11 +112,8 @@ def test_load_wrong_typed_ordering_key_raises_corrupt(tmp_path: Path):
|
||||
]
|
||||
),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(CorruptOutboxError, match="outbox corrupt"):
|
||||
OutboxStore(tmp_path / "outbox")
|
||||
raise AssertionError("expected CorruptOutboxError")
|
||||
except CorruptOutboxError:
|
||||
pass
|
||||
|
||||
|
||||
def test_load_wrong_typed_expected_remote_version_raises_corrupt(tmp_path: Path):
|
||||
@@ -128,21 +121,15 @@ def test_load_wrong_typed_expected_remote_version_raises_corrupt(tmp_path: Path)
|
||||
"outbox.json",
|
||||
json.dumps([{"mutation_id": "m", "remote_target": "t", "expected_remote_version": "0"}]),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(CorruptOutboxError, match="outbox corrupt"):
|
||||
OutboxStore(tmp_path / "outbox")
|
||||
raise AssertionError("expected CorruptOutboxError")
|
||||
except CorruptOutboxError:
|
||||
pass
|
||||
|
||||
|
||||
def test_mark_missing_mutation_id_raises_keyerror(tmp_path: Path):
|
||||
store = OutboxStore(tmp_path / "outbox")
|
||||
r = OutboxRecord(mutation_id="ghost", remote_target="t", expected_remote_version=0)
|
||||
try:
|
||||
with pytest.raises(KeyError, match="ghost"):
|
||||
store.mark(r)
|
||||
raise AssertionError("expected KeyError")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def test_pending_set_membership_retryable_in_terminal_out(tmp_path: Path):
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""test_retirement_conversion_change_trust.py — retirement-conversion
|
||||
wave 2 (rows 5 file-change-safety + 6 trust-and-sovereignty) integration
|
||||
battery, PORT → INTEGRATE → REMOVE (owner-amended; NO Python deletion).
|
||||
|
||||
1. persona_gate.decide resolves through the Go engine
|
||||
(src/go/persona-gate, `py-decide` parity surface) by default, with
|
||||
the Python oracle retained as escape hatch (RUNE_PERSONA_GATE_ENGINE
|
||||
=python) and fail-loud semantics (no silent fallback).
|
||||
2. stage_guards deterministic kernels (liveness_canary, reconcile)
|
||||
resolve through the Go engine (src/go/stage-guards) by default, with
|
||||
the same escape hatch + fail-loud discipline.
|
||||
3. Both paths agree on the truth tables (delegation is parity, not a
|
||||
divergent twin).
|
||||
|
||||
Red-first: written against the delegation contract before the facades
|
||||
were wired.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
GO_PERSONA_BIN = os.path.join(
|
||||
os.path.dirname(__file__), "..", "src", "go", "persona-gate", "persona-gate")
|
||||
GO_STAGE_BIN = os.path.join(
|
||||
os.path.dirname(__file__), "..", "src", "go", "stage-guards", "stage-guards")
|
||||
|
||||
|
||||
RULES = [
|
||||
{"text": "never touch the live game environment",
|
||||
"topic": "boundaries", "priority": 0, "restrictive": True,
|
||||
"user_rule": True, "words": ["live", "game", "environment", "touch"],
|
||||
"core": ""},
|
||||
{"text": "always journal after memory edits",
|
||||
"topic": "memory", "priority": 2, "restrictive": False,
|
||||
"user_rule": False, "words": ["journal", "memory", "edits"],
|
||||
"core": ""},
|
||||
]
|
||||
|
||||
|
||||
def _py_decide(text, rules):
|
||||
from rune.persona_gate import _py_decide as pd
|
||||
return pd(text, rules)
|
||||
|
||||
|
||||
# ── persona_gate: Go delegation ─────────────────────────────────────────────
|
||||
|
||||
def test_persona_gate_default_routes_through_go_engine():
|
||||
from rune import persona_gate
|
||||
verdict = persona_gate.decide("please update the live game environment",
|
||||
rules=[dict(r) for r in RULES])
|
||||
assert verdict["verdict"] == "allow" # permissive mode never refuses
|
||||
assert verdict["engaged"], "rule engagement must be scored"
|
||||
|
||||
|
||||
def test_persona_gate_go_matches_python_oracle():
|
||||
from rune import persona_gate
|
||||
texts = [
|
||||
"please update the live game environment",
|
||||
"what time is it",
|
||||
"journal the memory edits now",
|
||||
"completely unrelated chatter about pasta",
|
||||
]
|
||||
for text in texts:
|
||||
go = persona_gate.decide(text, rules=[dict(r) for r in RULES])
|
||||
py = _py_decide(text, [dict(r) for r in RULES])
|
||||
assert go == py, f"Go/Python parity broke for {text!r}"
|
||||
|
||||
|
||||
def test_persona_gate_python_escape_hatch(monkeypatch):
|
||||
monkeypatch.setenv("RUNE_PERSONA_GATE_ENGINE", "python")
|
||||
from rune import persona_gate
|
||||
verdict = persona_gate.decide("please update the live game environment",
|
||||
rules=[dict(r) for r in RULES])
|
||||
assert verdict == _py_decide("please update the live game environment",
|
||||
[dict(r) for r in RULES])
|
||||
|
||||
|
||||
def test_persona_gate_fail_loud_when_go_binary_missing(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("RUNE_PERSONA_GATE_BIN", str(tmp_path / "absent-bin"))
|
||||
monkeypatch.delenv("RUNE_PERSONA_GATE_ENGINE", raising=False)
|
||||
from rune import persona_gate
|
||||
with pytest.raises(RuntimeError, match="persona-gate go engine"):
|
||||
persona_gate.decide("hello", rules=[dict(r) for r in RULES])
|
||||
|
||||
|
||||
# ── stage_guards: Go delegation ─────────────────────────────────────────────
|
||||
|
||||
def test_stage_guards_liveness_routes_through_go_engine():
|
||||
from rune.stage_guards import _py_liveness_canary, liveness_canary
|
||||
truth = [(0.0, 100.0, 300.0, "ALIVE"),
|
||||
(0.0, 300.0, 300.0, "ALIVE"),
|
||||
(0.0, 301.0, 300.0, "STALLED"),
|
||||
(50.0, 50.5, 0.4, "STALLED"),
|
||||
(0.0, 1.23456, 5.0, "ALIVE")]
|
||||
for last, now, limit, verdict in truth:
|
||||
r = liveness_canary(last, now=now, stall_limit_secs=limit)
|
||||
assert r["verdict"] == verdict
|
||||
assert r == _py_liveness_canary(last, now, limit)
|
||||
|
||||
|
||||
def test_stage_guards_reconcile_routes_through_go_engine():
|
||||
from rune.stage_guards import _py_reconcile, reconcile
|
||||
rows = [
|
||||
({"a": 1, "b": "x"}, {"a": 1, "b": "x"}, set(), "s1"),
|
||||
({"a": 1}, {"a": 2}, set(), "s1"),
|
||||
({"a": 1, "b": 2}, {"a": 9, "b": 2}, {"a"}, "s1"),
|
||||
({"a": 1}, {}, set(), "s2"),
|
||||
({"a": 1}, "not-a-dict", set(), "s3"),
|
||||
({}, {"a": 1}, set(), "s4"),
|
||||
({"cfg": {"x": 1}}, {"cfg": {"x": 2}}, set(), "s5"),
|
||||
]
|
||||
for desired, actual, tol, sid in rows:
|
||||
r = reconcile(desired, lambda a=actual: a, tolerance=tol, stage_id=sid)
|
||||
assert r == _py_reconcile(desired, actual, tol, sid), (r, sid)
|
||||
|
||||
|
||||
def test_stage_guards_python_escape_hatch(monkeypatch):
|
||||
monkeypatch.setenv("RUNE_STAGE_GUARDS_ENGINE", "python")
|
||||
from rune.stage_guards import _py_liveness_canary, _py_reconcile
|
||||
import rune.stage_guards as sg
|
||||
assert sg.liveness_canary(0.0, now=301.0, stall_limit_secs=300.0) == \
|
||||
_py_liveness_canary(0.0, 301.0, 300.0)
|
||||
assert sg.reconcile({"a": 1}, lambda: {"a": 2}, stage_id="s1") == \
|
||||
_py_reconcile({"a": 1}, {"a": 2}, set(), "s1")
|
||||
|
||||
|
||||
def test_stage_guards_fail_loud_when_go_binary_missing(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("RUNE_STAGE_GUARDS_BIN", str(tmp_path / "absent-bin"))
|
||||
monkeypatch.delenv("RUNE_STAGE_GUARDS_ENGINE", raising=False)
|
||||
from rune import stage_guards
|
||||
with pytest.raises(RuntimeError, match="stage-guards go engine"):
|
||||
stage_guards.liveness_canary(0.0, now=1.0, stall_limit_secs=300.0)
|
||||
with pytest.raises(RuntimeError, match="stage-guards go engine"):
|
||||
stage_guards.reconcile({"a": 1}, lambda: {"a": 1}, stage_id="s1")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""rune_tui tasks-lane fold — the "task"/"tasks" lane-key quirk (red-first).
|
||||
|
||||
Defect sites (rune_tui.py, startup parallel loader):
|
||||
|
||||
- load_tasks() returns the lane tuple ("tasks", active, open_pins) —
|
||||
THREE elements — but the consumer folded every parallel result with
|
||||
`key, value = result`, a 2-tuple unpack. On any tasks result with
|
||||
pins the unpack raised ValueError, so the "tasks" lane branch never
|
||||
ran: no active-task summary, no pin count, and (before the
|
||||
ValueError) nothing rendered.
|
||||
- the tasks branch rendered `t.get("summary")` while the canonical
|
||||
loader emits `{"task_id", "title", "status"}` — the summary was
|
||||
always empty even when the fold worked.
|
||||
|
||||
Pinned here via the pure fold helper; the loader keeps emitting the
|
||||
"tasks" lane key (the channel stays "task" — distinct vocabularies, by
|
||||
design), and the renderer accepts both summary and title keys.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src" / "rune"))
|
||||
|
||||
from rune_tui import fold_lane_result # noqa: E402
|
||||
|
||||
|
||||
def test_tasks_lane_three_tuple_folds():
|
||||
active = [{"task_id": "t1", "title": "Fix login", "status": "running"}]
|
||||
pins = [{"id": "p1", "status": "open"}]
|
||||
key, value, extra = fold_lane_result(("tasks", active, pins))
|
||||
assert key == "tasks"
|
||||
assert value == active
|
||||
assert extra == pins
|
||||
|
||||
|
||||
def test_two_tuple_results_keep_folding():
|
||||
key, value, extra = fold_lane_result(("persona", True))
|
||||
assert (key, value, extra) == ("persona", True, None)
|
||||
|
||||
|
||||
def test_tasks_lane_renders_title_when_summary_absent():
|
||||
# the renderer-side leg: title (the loader's key) must surface
|
||||
row = {"task_id": "t1", "title": "Fix the login bug", "status": "running"}
|
||||
assert (row.get("summary") or row.get("title") or "")[:60] == "Fix the login bug"
|
||||
@@ -30,7 +30,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
WORKFLOW_DIR = ROOT / ".gitea" / "workflows"
|
||||
SCRIPTS_DIR = ROOT / "scripts"
|
||||
|
||||
WORKFLOWS = ("auto-version.yml", "maintenance.yml")
|
||||
WORKFLOWS = ("auto-version.yml", "maintenance.yml", "tui-release.yml")
|
||||
|
||||
# Hard env reads: os.environ["KEY"] / os.environ['KEY'] (KeyError risk).
|
||||
REQUIRED_ENV_RE = re.compile(r"""os\.environ\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]""")
|
||||
@@ -214,3 +214,42 @@ def test_boolean_env_exports_are_lowercase(workflow_name: str, job: str, run: st
|
||||
f"(str(...).lower()) or use lowercase literals — conditionals compare "
|
||||
f"env.CONVERGED == 'false'-style strings: {offenders}"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SIGPIPE pipe guard + GITEA_REF_NAME tag-fallback guard (wave-ci-e2e-rig)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SIGPIPE_PIPE_RE = re.compile(r"git tag\b[^\n|]*\|[^\n]*\bhead\b")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workflow_name", WORKFLOWS, ids=lambda w: w)
|
||||
def test_no_sigpipe_git_tag_head_pipe(workflow_name: str) -> None:
|
||||
"""`git tag --sort=-v:refname | head -1` under pipefail exits 141
|
||||
(SIGPIPE/EPIPE) once the tag count grows — the runs-29929/30435/30636
|
||||
regression killed the bump step on its first line. Load-bearing
|
||||
latest-tag reads must be SIGPIPE-free (tag_repair.py / for-each-ref);
|
||||
any residual `git tag … | head` pipeline must be failure-tolerant."""
|
||||
workflow = _load_workflow(workflow_name)
|
||||
offenders = []
|
||||
for job_name, run, idx in _run_steps(workflow_name):
|
||||
for line in run.splitlines():
|
||||
code = re.sub(r"`[^`]*`", "", line) # ignore backtick mentions
|
||||
code = code.split("#", 1)[0] # ignore comments
|
||||
if SIGPIPE_PIPE_RE.search(code) and "|| true" not in line:
|
||||
offenders.append(f"{job_name}/step{idx}: {line.strip()}")
|
||||
assert not offenders, (
|
||||
f"{workflow_name}: SIGPIPE-prone `git tag … | head` pipeline without "
|
||||
f"|| true — under set -o pipefail this exits 141 and kills the step "
|
||||
f"(runs 29929/30435/30636): {offenders}"
|
||||
)
|
||||
|
||||
def test_tui_release_tag_fallback_chain_is_intact() -> None:
|
||||
"""GITEA_REF_NAME resolves EMPTY on the rune-ci-desktop runner even on
|
||||
tag pushes (runs 30390/30456/30862). tui-release must resolve its tag
|
||||
through the full fallback chain, not bare GITEA_REF_NAME."""
|
||||
src = (WORKFLOW_DIR / "tui-release.yml").read_text(encoding="utf-8")
|
||||
assert '${GITEA_REF_NAME:-${GITHUB_REF_NAME:-$(git describe --tags --exact-match' in src, (
|
||||
"tui-release.yml tag resolution must keep the fallback chain "
|
||||
"GITEA_REF_NAME → GITHUB_REF_NAME → git describe --exact-match — "
|
||||
"a bare GITEA_REF_NAME read aborts the release with an empty tag"
|
||||
)
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/runtime/lib"
|
||||
)
|
||||
|
||||
// TestTUIWiringPointsAtGoFrontDoor freezes the rebase-TUI-wiring law
|
||||
// (contract ADMIT-wave-rebase-tui-wiring): the TUI's serve-HTTP path
|
||||
// targets the GO front door (rune serve, 127.0.0.1:8377) — never the
|
||||
// raw core port (8376) — and the identity line names the honest source
|
||||
// ("lib" for the library-direct path, "serve" for the fallback).
|
||||
func TestTUIWiringPointsAtGoFrontDoor(t *testing.T) {
|
||||
if !strings.HasSuffix(DefaultBase, ":8377") {
|
||||
t.Fatalf("DefaultBase must be the Go front door :8377, got %q", DefaultBase)
|
||||
}
|
||||
if strings.Contains(DefaultBase, "8376") {
|
||||
t.Fatalf("DefaultBase must never point at the raw core :8376, got %q", DefaultBase)
|
||||
}
|
||||
}
|
||||
|
||||
// stubSource is a minimal non-nil lib.Source — enough to flip the
|
||||
// bridge onto the library-direct path for the label pin.
|
||||
type stubSource struct{}
|
||||
|
||||
func (stubSource) Status(context.Context) (lib.StatusRow, error) { return lib.StatusRow{}, nil }
|
||||
func (stubSource) Tasks(context.Context) ([]lib.TaskRow, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
func (stubSource) Conversation(context.Context, string, int) ([]lib.ConvRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (stubSource) Events(context.Context, int) ([]lib.EventRow, error) { return nil, nil }
|
||||
func (stubSource) Close() {}
|
||||
|
||||
// TestSourceLabelNamesNativeSource freezes the identity-line law: the
|
||||
// label comes from the wired engine, not from a constant claim.
|
||||
func TestSourceLabelNamesNativeSource(t *testing.T) {
|
||||
lib := NewLibrary(stubSource{}, 0)
|
||||
if got := lib.SourceLabel(); got != "lib" {
|
||||
t.Fatalf("library path must label \"lib\", got %q", got)
|
||||
}
|
||||
srv := New(NewServeClient(DefaultBase), 0)
|
||||
if got := srv.SourceLabel(); got != "serve" {
|
||||
t.Fatalf("serve path must label \"serve\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeClientTargetsFrontDoor: the ServeClient's base is used
|
||||
// verbatim as the polled URL prefix — so an accidentally retargeted
|
||||
// base toward the Python core :8376 fails here, not silently in prod.
|
||||
func TestServeClientTargetsFrontDoor(t *testing.T) {
|
||||
c := NewServeClient(DefaultBase)
|
||||
if c.base != DefaultBase {
|
||||
t.Fatalf("ServeClient must poll the given base verbatim, got %q", c.base)
|
||||
}
|
||||
if _, err := c.Status(context.Background()); err == nil {
|
||||
t.Log("live front door answered (integrated environment)")
|
||||
}
|
||||
}
|
||||
+141
-70
@@ -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
@@ -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
@@ -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) ───
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,101 @@ func openPty() (master, slave *os.File, err error) {
|
||||
return master, slave, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// watchdog read machinery (wave boot-smoke-watchdog). Every pty read in
|
||||
// this file goes through it: a dedicated reader goroutine feeds
|
||||
// ptyChunk values into a channel; each phase selects against a REAL
|
||||
// deadline, so a read that never returns can never hold a phase past
|
||||
// its bound — the exact hang the go-sharding receipt diagnosed in the
|
||||
// old blocking master.Read, which burned go test's 10-minute timeout
|
||||
// per strike.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ptyChunk is one read result from the pty reader goroutine.
|
||||
type ptyChunk struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// startPtyReader owns all reads of master. The channel is buffered so
|
||||
// the goroutine can deliver a final error chunk even after the phase
|
||||
// loop has stopped consuming. When the child dies the read unblocks
|
||||
// with EIO and the goroutine drains and exits — a reader is never left
|
||||
// parked in a read the watchdog has abandoned.
|
||||
func startPtyReader(master *os.File) <-chan ptyChunk {
|
||||
ch := make(chan ptyChunk, 256)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := master.Read(buf)
|
||||
b := make([]byte, n)
|
||||
copy(b, buf[:n])
|
||||
ch <- ptyChunk{data: b, err: err}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// readUntilMarker runs one bounded phase: consume chunks (appending to
|
||||
// sink) until marker(sink) fires, the child exits, or the deadline
|
||||
// passes — whichever comes first. A hung or silent reader therefore
|
||||
// ends the phase at the declared bound with a diagnosed error instead
|
||||
// of an unbounded block.
|
||||
func readUntilMarker(chunks <-chan ptyChunk, exited <-chan error,
|
||||
deadline time.Time, sink *strings.Builder,
|
||||
marker func(string) bool) (bool, error) {
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return false, errWatchdogDeadline
|
||||
}
|
||||
select {
|
||||
case c, ok := <-chunks:
|
||||
if len(c.data) > 0 {
|
||||
sink.Write(c.data)
|
||||
}
|
||||
if marker(sink.String()) {
|
||||
return true, nil
|
||||
}
|
||||
if !ok {
|
||||
select {
|
||||
case err := <-exited:
|
||||
return false, fmt.Errorf("child exited with the pty reader closed (rc=%v): %w", err, errWatchdogChildExited)
|
||||
case <-time.After(remaining):
|
||||
return false, errWatchdogDeadline
|
||||
}
|
||||
}
|
||||
if c.err != nil && !os.IsTimeout(c.err) {
|
||||
return false, c.err
|
||||
}
|
||||
case err := <-exited:
|
||||
if marker(sink.String()) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("child exited before the phase completed (rc=%v): %w", err, errWatchdogChildExited)
|
||||
case <-time.After(remaining):
|
||||
return false, errWatchdogDeadline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// killGroup kills the child's whole process group (the face runs as a
|
||||
// session leader, so its go-build children die with it). Falls back to
|
||||
// the direct kill when the group signal cannot be delivered.
|
||||
func killGroup(cmd *exec.Cmd) error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := unix.Kill(-cmd.Process.Pid, syscall.SIGKILL); err == nil {
|
||||
return nil
|
||||
}
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
|
||||
// TestBootSmokeFaceReachesLibConnected is the frozen user's boot: the
|
||||
// real face binary on a real pty must boot AND run its bridge on the
|
||||
// LIBRARY seam. D43.12 stripped the identity line's `runtime
|
||||
@@ -135,41 +230,25 @@ func TestBootSmokeFaceReachesLibConnected(t *testing.T) {
|
||||
|
||||
exited := make(chan error, 1)
|
||||
go func() { exited <- cmd.Wait() }()
|
||||
chunks := startPtyReader(master)
|
||||
|
||||
// generous deadline: a cold cache compiles rune-entry + event-bus
|
||||
// inside the child before the bridge can ever poll
|
||||
// inside the child before the bridge can ever poll. The watchdog
|
||||
// reader goroutine owns every read, so even a read that never
|
||||
// returns cannot outlive this deadline (the boot-smoke hang).
|
||||
const homeMarker = "┌" // the composer box's solid top border (the boot's first frame)
|
||||
deadline := time.Now().Add(240 * time.Second)
|
||||
var seen strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
reached := false
|
||||
for !reached && time.Now().Before(deadline) {
|
||||
select {
|
||||
case err := <-exited:
|
||||
t.Fatalf("face exited before booting (rc=%v) — boot output:\n%.2000s",
|
||||
err, semantic.StripANSI(seen.String()))
|
||||
default:
|
||||
}
|
||||
if err := master.SetReadDeadline(time.Now().Add(1 * time.Second)); err != nil {
|
||||
t.Fatalf("pty read deadline: %v", err)
|
||||
}
|
||||
n, err := master.Read(buf)
|
||||
if n > 0 {
|
||||
seen.Write(buf[:n])
|
||||
if strings.Contains(semantic.StripANSI(seen.String()), homeMarker) {
|
||||
reached = true
|
||||
}
|
||||
}
|
||||
if err != nil && !os.IsTimeout(err) {
|
||||
t.Fatalf("pty read: %v — boot output:\n%.2000s", err,
|
||||
semantic.StripANSI(seen.String()))
|
||||
}
|
||||
}
|
||||
if !reached {
|
||||
_ = cmd.Process.Kill()
|
||||
reached, rerr := readUntilMarker(chunks, exited, deadline, &seen,
|
||||
func(s string) bool { return strings.Contains(semantic.StripANSI(s), homeMarker) })
|
||||
if rerr != nil {
|
||||
killGroup(cmd)
|
||||
<-exited
|
||||
plain := semantic.StripANSI(seen.String())
|
||||
t.Fatalf("boot never rendered its home within 240s — boot output:\n%.2000s", plain)
|
||||
// diagnosed failure at the declared bound: the face never
|
||||
// rendered its home (hung boot, silent pty, or early exit)
|
||||
t.Fatalf("boot never rendered its home within 240s (%v) — boot output:\n%.2000s",
|
||||
rerr, plain)
|
||||
}
|
||||
plain := semantic.StripANSI(seen.String())
|
||||
if strings.Contains(plain, "library seam unavailable") {
|
||||
@@ -184,35 +263,14 @@ func TestBootSmokeFaceReachesLibConnected(t *testing.T) {
|
||||
}
|
||||
const feedMarker = "[rune] stream"
|
||||
deadline = time.Now().Add(60 * time.Second)
|
||||
fed := false
|
||||
for !fed && time.Now().Before(deadline) {
|
||||
select {
|
||||
case err := <-exited:
|
||||
t.Fatalf("face exited before the bridge polled (rc=%v) — boot output:\n%.2000s",
|
||||
err, semantic.StripANSI(seen.String()))
|
||||
default:
|
||||
}
|
||||
if err := master.SetReadDeadline(time.Now().Add(1 * time.Second)); err != nil {
|
||||
t.Fatalf("pty read deadline: %v", err)
|
||||
}
|
||||
n, err := master.Read(buf)
|
||||
if n > 0 {
|
||||
seen.Write(buf[:n])
|
||||
if strings.Contains(semantic.StripANSI(seen.String()), feedMarker) {
|
||||
fed = true
|
||||
}
|
||||
}
|
||||
if err != nil && !os.IsTimeout(err) {
|
||||
t.Fatalf("pty read: %v — boot output:\n%.2000s", err,
|
||||
semantic.StripANSI(seen.String()))
|
||||
}
|
||||
}
|
||||
if !fed {
|
||||
_ = cmd.Process.Kill()
|
||||
fed, ferr := readUntilMarker(chunks, exited, deadline, &seen,
|
||||
func(s string) bool { return strings.Contains(semantic.StripANSI(s), feedMarker) })
|
||||
if ferr != nil {
|
||||
killGroup(cmd)
|
||||
<-exited
|
||||
t.Fatalf("the bridge never polled a live status round within 60s "+
|
||||
t.Fatalf("the bridge never polled a live status round within 60s (%v) "+
|
||||
"(the takeover feed carries no %q entry) — boot output:\n%.2000s",
|
||||
feedMarker, semantic.StripANSI(seen.String()))
|
||||
ferr, feedMarker, semantic.StripANSI(seen.String()))
|
||||
}
|
||||
// restore the lanes before the clean detach (esc)
|
||||
_, _ = master.Write([]byte("\x1b"))
|
||||
@@ -225,8 +283,8 @@ func TestBootSmokeFaceReachesLibConnected(t *testing.T) {
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(15 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
killGroup(cmd)
|
||||
t.Fatal("face did not exit on ctrl+c within 15s — the boot cannot be ended cleanly")
|
||||
}
|
||||
t.Logf("boot smoke: home rendered + bridge polled live (frozen user's boot, real pty; reached=%v)", reached)
|
||||
t.Logf("boot smoke: home rendered + bridge polled live (frozen user's boot, real pty; reached=%v fed=%v)", reached, fed)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
// boot_watchdog_test.go — the red-first watchdog suite for wave
|
||||
// boot-smoke-watchdog (contract ADMIT-wave-boot-smoke-watchdog,
|
||||
// approval user:50-scale distribution 2026-10-20). The named repair:
|
||||
// the frozen boot smoke test hung in an unbounded pty master Read past
|
||||
// its own deadlines (600s strikes, leaked orphans). These tests pin
|
||||
// the watchdog laws BEFORE the implementation exists:
|
||||
//
|
||||
// 1. a hung reader (a read channel that never delivers) cannot hold a
|
||||
// phase past its declared bound — the loop dies at the bound with
|
||||
// a diagnosed result;
|
||||
// 2. a child spawned under the watchdog is killed on timeout (its
|
||||
// whole process group) — the leaked-process count after the run
|
||||
// is ZERO.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errWatchdogDeadline = errors.New("watchdog: phase exceeded its declared deadline")
|
||||
errWatchdogChildExited = errors.New("watchdog: child exited before the phase completed")
|
||||
)
|
||||
|
||||
// TestWatchdogHungReaderDiesAtDeclaredBound simulates the exact
|
||||
// failure the go-sharding receipt diagnosed: a read that never
|
||||
// returns. With the watchdog (reader goroutine + channel + select
|
||||
// against a real deadline) the phase MUST end at its declared bound —
|
||||
// not the 10-minute go-test timeout.
|
||||
func TestWatchdogHungReaderDiesAtDeclaredBound(t *testing.T) {
|
||||
hung := make(chan ptyChunk) // never delivers: the hung read
|
||||
neverExited := make(chan error)
|
||||
sink := &strings.Builder{}
|
||||
|
||||
done := make(chan struct{})
|
||||
var matched bool
|
||||
go func() {
|
||||
defer close(done)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
matched, _ = readUntilMarker(hung, neverExited, deadline, sink,
|
||||
func(s string) bool { return strings.Contains(s, "never") })
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if matched {
|
||||
t.Fatal("hung reader matched a marker it never delivered")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("watchdog phase outlived its 2s declared bound — " +
|
||||
"the unbounded-read hang is back")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatchdogTimeoutKillsChildZeroLeak spawns a real child (a plain
|
||||
// sleeper standing in for the face, session leader like the pty boot),
|
||||
// lets the watchdog time out against it, and requires that NO process
|
||||
// from its group survives: the leaked-process count after the run is
|
||||
// ZERO.
|
||||
func TestWatchdogTimeoutKillsChildZeroLeak(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "300")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // session leader, like the pty face
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skipf("no sleep(1) available: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
neverChunks := make(chan ptyChunk)
|
||||
neverExited := make(chan error, 1)
|
||||
go func() { neverExited <- cmd.Wait() }()
|
||||
|
||||
start := time.Now()
|
||||
_, err := readUntilMarker(neverChunks, neverExited,
|
||||
time.Now().Add(1*time.Second), &strings.Builder{},
|
||||
func(string) bool { return false })
|
||||
if err == nil {
|
||||
t.Fatal("watchdog reported success against a child that never produced output")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Fatalf("timeout kill took %s — the bound is not real", elapsed)
|
||||
}
|
||||
|
||||
// the declared kill path: the whole process group dies
|
||||
if err := killGroup(cmd); err != nil {
|
||||
t.Fatalf("killGroup: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
out, perr := exec.Command("pgrep", "-g", strconv.Itoa(pid)).Output()
|
||||
if perr != nil || len(strings.Fields(string(out))) == 0 {
|
||||
break // no group members alive: zero leaks
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("leaked processes after watchdog timeout: %s", out)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+424
-27
@@ -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
|
||||
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user