Compare commits

...
22 Commits
Author SHA1 Message Date
conductor 807199c089 chore(release): auto-bump version to 0.333.1 — earned by # (patch: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 39s
CI Pipeline / best-practices (push) Successful in 16s
deploy / deploy (push) Skipped
CI Pipeline / rust-surface (push) Failing after 26s
CI Pipeline / documentation (push) Successful in 10s
CI Pipeline / gitea-offline (push) Successful in 24s
TUI Release / release (push) Successful in 33s
CI Pipeline / go-surface (push) Failing after 53s
CI Pipeline / lint (push) Failing after 1m6s
CI Pipeline / type-check (push) Failing after 1m0s
CI Pipeline / canary (push) Failing after 3m42s
CI Pipeline / unit-tests (push) Failing after 30m59s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
Promotion record: (unknown)
2026-10-22 09:20:05 +01:00
Nick 24cafe1391 fix(envelope-door): unwrap-then-classify INTERNAL_ENVELOPE payloads in the scaffolding-guard turn boundary (adversarial-escape Door 1, red-first)
CI Pipeline / documentation (push) Successful in 14s
CI Pipeline / best-practices (push) Successful in 15s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 17s
CI Pipeline / go-surface (push) Canceled after 19s
CI Pipeline / type-check (push) Canceled after 3s
CI Pipeline / lint (push) Canceled after 21s
CI Pipeline / unit-tests (push) Canceled after 21s
Auto-version and Release / release (push) Canceled after 21s
CI Pipeline / canary (push) Canceled after 21s
CI Pipeline / gitea-offline (push) Canceled after 7s
deploy / deploy (push) Canceled after 5s
The turn boundary skipped classification for ANY user-role text
starting with '<', '[inbox', '[Never-Ask]' or 'EXTRACTION REQUIRED'
— a cron/envelope-shaped BUILD instruction minted no contract and
mutated ungoverned until the promotion backstop. Now the envelope
METADATA is exempt, never the CONTENT: unwrapEnvelope strips the
envelope layers (closed tags to their inner text; unclosed leading
tags, bracket groups and banners as metadata) and the payload runs
the same begin classification with origin='envelope'. A payloadless
envelope (pure actor notification) still classifies nothing.

Canonical copy synced to ~/.mimocode/hooks/ via install_mimo_hook.py.
2026-10-22 09:13:08 +01:00
Nick 586dc3b9c4 fix(spawn-door): canonical subagent-protocol-guard in the MANAGED set + tightened lexical pass (adversarial-escape Door 5, red-first)
The spawn guard was unversioned (no canonical copy, absent from
install_mimo_hook.py MANAGED — the drift-undetected rot that killed
builder-bridge pre-T22) and lexically porous. Canonical copy now lives
in config/mimo-hooks/ and is hash-drift-checked like its siblings;
installed copy synced.

Tightened classification (classifyBrief, exported for direct testing):
- a build brief needs the admission marker AND a target surface (repo
  path or project name, judged outside the admission boilerplate) —
  marker-only briefs are refused with typed reasons
  BUILD_BRIEF_NO_ADMISSION / BUILD_BRIEF_NO_TARGET;
- build markers match on word boundaries (a 'report' mention is no
  longer build-shaped via substring 'port');
- the research exemption requires an explicit leading 'research:'
  prefix — self-declared 'research artifacts to ...' briefs are no
  longer exempt.
2026-10-22 09:09:23 +01:00
conductor 930b4dbf55 chore(release): auto-bump version to 0.333.0 — earned by # (minor: converged release (push-to-master bump))
deploy / deploy (push) Skipped
Auto-version and Release / release (push) Successful in 19s
CI Pipeline / best-practices (push) Successful in 10s
TUI Release / release (push) Successful in 23s
CI Pipeline / documentation (push) Successful in 7s
CI Pipeline / rust-surface (push) Failing after 26s
CI Pipeline / go-surface (push) Failing after 39s
CI Pipeline / gitea-offline (push) Successful in 27s
CI Pipeline / lint (push) Failing after 51s
CI Pipeline / type-check (push) Failing after 54s
CI Pipeline / canary (push) Failing after 3m25s
CI Pipeline / unit-tests (push) Failing after 28m50s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 4s
Promotion record: (unknown)
2026-10-22 08:06:52 +01:00
Nick 541264812a test(vitality): pin capture-failure input guards; record wave progress + findings
CI Pipeline / best-practices (push) Successful in 17s
CI Pipeline / documentation (push) Successful in 20s
CI Pipeline / rust-surface (push) Failing after 24s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 6s
CI Pipeline / go-surface (push) Canceled after 30s
CI Pipeline / type-check (push) Canceled after 9s
Auto-version and Release / release (push) Canceled after 34s
CI Pipeline / lint (push) Canceled after 32s
CI Pipeline / unit-tests (push) Canceled after 31s
CI Pipeline / canary (push) Canceled after 31s
deploy / deploy (push) Canceled after 5s
3 panic-only sites converted (unknown --source, unsafe failure-class and
fixture-id slug guards), mutation-verified red-first. Audit report
updated: refined sweep counts 123 true panic-only sites / 53 files,
40 converted across 4 clusters so far; records the pre-existing
structural crash-parity kernel/oracle message divergence and the
__pycache__ second-granularity mutation-verification pitfall.
2026-10-22 08:03:17 +01:00
Nick 639530b2cf fix(tui): D47 OSC 8 detection gate + pins — the denylist diagnosis (the binary was never stale; the old unknown-terminal default silently dropped the link), the model-token click red-first pins, and the tui/semantic keyboard tests re-pinned to the CURRENT D46 exit-gesture law (q types, ctrl+c twice quits) 2026-10-22 07:46:32 +01:00
Nick 75f872165b feat(rune-core): D47 per-model context readout — the registry's per-model limit.context rides the routed cloud clients (openai/anthropic/gemini) with the wire usage as ContextUsed; locals keep the per-model /api/show source; the readout always names the model actually serving 2026-10-22 07:44:23 +01:00
Nick 0fd8549f8b fix(budget): D47 whole-turn dispatch budget — the deadline covers the SUM of the tool loop's model calls (6 × the per-call 480s) not one call, per model class (#2533/#2536: locals get the extended whole-turn budget, cloud shorter); every leg carries it: the lib completion client + composer ctxs, both entry doors' proxy clients 2026-10-22 07:44:22 +01:00
Nick 4e92bd573a feat(tui-shell): D47 answer routing + model-token click — the shell routes live answers' fenced blocks to the work stream on both answer paths, and a click on the info bar's model token opens the centered searchable picker (the picker's click road beside ctrl+m) 2026-10-22 07:40:53 +01:00
Nick 4430b1ee14 feat(tui-tasks): D47 answer-code routing — fenced blocks from answers route to the tasks pane's work stream (D44: code = work content), bounded stream with the D36 expandable rendering on the e key; chat stays conversational 2026-10-22 07:40:52 +01:00
Nick 241e8c1e97 feat(tui-chatpane): D47 markdown-to-pane projection — paragraphs as paragraphs, real markdown bullet entries only as bullets (hanging indent), inline markers honestly stripped, code fences intact per D36; the per-sentence-bullet misrender is structurally impossible 2026-10-22 07:39:51 +01:00
Nick 9f39c40bfa test(vitality): pin skills-engine law refusals and structural raise parity
Convert 13 panic-only sites. skills_engine: owner-envelope consent
refusal, declined-pattern re-consent, approve-before-tested, approve of
rejected, mutation-guard DENY spike, foreign untested approve — each now
pins the SkillLawError message. structural: unknown entity kind /
relationship refusal asserted on BOTH cores (kernel + RUNE_STM_CORE=
python floor), alias target-not-found / self-alias, promote
missing-evidence / unknown-maturity — dual-mode pins make the src/rune
guards mutation-verifiable despite kernel-first dispatch.

Mutation proofs: entity-kind check, self-alias check, promote evidence
and maturity checks disabled — each turned exactly the new test red;
sources reverted byte-identical.
2026-10-22 06:56:49 +01:00
Nick f3092dbdaa fix(git-door): construction-contract gate on the staged tree + pathless remediation exit closed (adversarial-escape Door 4, red-first)
rule 5 in check_landing.py --staged-only: a staged MUTATING path
(non-test, non-docs source) must be covered by an OPEN
construction contract or the pre-commit hook refuses, naming the
contract ids checked and the admit_wave_* admission door. The only
escape hatch is scripts/admit_wave_*.py itself.

check_mutation's remediation exit no longer grants remediation to
PATHLESS commands while a scope violation is unresolved — a command
that resolves no repo paths (git add -A && git commit) cannot prove
it targets only the offending paths; it is refused as
SCOPE_VIOLATION_UNRESOLVED with a typed note.

Admission: contract ADMIT-2026-10-22T04:38:23Z-7d445e-wave-
adversarial-escape-highs (phase building, scope covers scripts/,
config/mimo-hooks/, src/rune/construction_authority.py, tests/).
2026-10-22 06:55:36 +01:00
Nick 6b42f18c2b ci(release): wire refresh-highlights CLI into auto-version so README Highlights refresh every release (#2538 follow-up)
CI Pipeline / best-practices (push) Successful in 13s
CI Pipeline / documentation (push) Successful in 13s
Auto-version and Release / release (push) Successful in 24s
CI Pipeline / rust-surface (push) Failing after 24s
CI Pipeline / gitea-offline (push) Successful in 31s
CI Pipeline / go-surface (push) Failing after 47s
CI Pipeline / lint (push) Failing after 58s
CI Pipeline / type-check (push) Failing after 56s
CI Pipeline / canary (push) Failing after 3m23s
deploy / deploy (push) Failing after 15m27s
CI Pipeline / unit-tests (push) Failing after 28m27s
CI Pipeline / required-checks (push) Failing after 3s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 6s
New step after the plain-English narrative prepend: builds the
release-notes module, runs refresh-highlights against the newest main
release, commits+pushes README.md only when the marker-scoped section
changed (noop short-circuits before any commit). Non-blocking: every
failure path is a ::warning:: + exit 0. Push auth reuses
tag_repair.py's tested extraheader mechanism. Contract guards added to
tests/test_workflow_script_contract.py (placement, CLI reference,
non-blocking, idempotency) with the strip-backticks-and-comments rule
so YAML comments cannot satisfy a guard.
2026-10-22 06:47:44 +01:00
Nick 66302f029e test(vitality): pin construction-surface guards against kernel-oracle ambiguity
Convert 8 panic-only sites: obligation key validation, registry revision
guard, pattern-proposal provider requirement, persisted-readback
assertion, EditNoOp/EditPostconditionFailed edit guards, USER-LOCKED
layout amendment refusal, invalid override-mode rejection.

Dual-core surfaces (get_pattern, amend_layout_contract) now assert the
pinned message on BOTH the Rust kernel path and the RUNE_*_CORE=python
oracle path, so the src/rune fallback guard is mutation-verifiable.
Mutation proofs: obligation-check disabled, revision guard disabled,
verify_persisted/EditNoOp/postcondition guards broken, user-lock check
disabled, MODES membership check disabled — each turned exactly the new
test red; production sources reverted byte-identical.
2026-10-22 05:03:03 +01:00
Nick 98d5dcd056 docs(readme): rewrite front page for the current Go architecture + auto-refreshable Highlights (#2538)
Auto-version and Release / release (push) Successful in 14s
CI Pipeline / best-practices (push) Successful in 14s
CI Pipeline / documentation (push) Successful in 14s
CI Pipeline / rust-surface (push) Failing after 21s
CI Pipeline / gitea-offline (push) Successful in 30s
CI Pipeline / go-surface (push) Failing after 45s
CI Pipeline / lint (push) Failing after 59s
CI Pipeline / type-check (push) Failing after 58s
CI Pipeline / canary (push) Failing after 3m24s
deploy / deploy (push) Failing after 15m31s
CI Pipeline / unit-tests (push) Failing after 28m3s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
README now describes what Rune is and does in plain English: the
chat/tasks dichotomy, task-mode tool use on the PC, persistent memory
and skills, the rune launcher, and the one-line architecture story
(Go core :8376 -> front door :8377 -> terminal chat window), kept
honest to the authority map.

Adds the marker pair (<!-- highlights:start/end -->) around a
Highlights section, and a release-notes refresh-highlights CLI that
rewrites ONLY that section from the latest main release's
plain-English narrative — idempotent, marker-scoped, red-first
battery with a fixture README. CI wiring is a follow-up.
2026-10-22 04:16:02 +01:00
Nick 1525717e5e test(vitality): pin guarded error surfaces in security-cluster raise-only tests
Convert 16 panic-only pytest.raises sites to message-pinned, code-checked
assertions: secret_store (MAC verification gate, keyring fail-backend,
missing module, secret-tool rc), build_attestation (rust/go mismatch,
missing stamp, malformed stamp, absent crate/binary), adapt9 artifact
reader (no arbitrary trailing-text salvage), construction_authority
(invalid relationship), capability_broker (non-iterable capabilities),
owner_envelope (protected display refuses unverified bytes).

Each pin mutation-verified red-first: breaking the guarded line turns the
new test red where the old raise-only form stayed green; production
sources reverted byte-identical.
2026-10-22 04:16:02 +01:00
conductor a0ea8eb051 chore(release): auto-bump version to 0.332.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 19s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 11s
CI Pipeline / documentation (push) Successful in 11s
CI Pipeline / rust-surface (push) Failing after 17s
CI Pipeline / gitea-offline (push) Successful in 30s
TUI Release / release (push) Successful in 25s
CI Pipeline / go-surface (push) Failing after 44s
CI Pipeline / lint (push) Failing after 54s
CI Pipeline / type-check (push) Failing after 56s
CI Pipeline / canary (push) Failing after 3m18s
CI Pipeline / unit-tests (push) Failing after 28m0s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
Promotion record: (unknown)
2026-10-22 03:12:50 +01:00
Nick 0c1747dd21 feat(release): plain-English jargon dictionary covers the v0.329 vocabulary + catch-all sentence simplification pass
Auto-version and Release / release (push) Canceled after 13s
CI Pipeline / documentation (push) Canceled after 12s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / best-practices (push) Canceled after 13s
CI Pipeline / go-surface (push) Canceled after 13s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / canary (push) Canceled after 13s
CI Pipeline / rust-surface (push) Canceled after 14s
deploy / deploy (push) Canceled after 6s
CI Pipeline / lint (push) Canceled after 21s
CI Pipeline / unit-tests (push) Canceled after 20s
2026-10-22 03:09:19 +01:00
Nick 9b17415d10 docs: dichotomy routing table DRAFT — assembled from owner rulings, holes marked for ruling
CI Pipeline / best-practices (push) Successful in 13s
CI Pipeline / documentation (push) Successful in 13s
Auto-version and Release / release (push) Successful in 14s
CI Pipeline / rust-surface (push) Failing after 21s
CI Pipeline / gitea-offline (push) Successful in 29s
CI Pipeline / go-surface (push) Failing after 46s
CI Pipeline / lint (push) Failing after 58s
CI Pipeline / type-check (push) Failing after 53s
CI Pipeline / canary (push) Failing after 3m19s
deploy / deploy (push) Failing after 15m28s
CI Pipeline / unit-tests (push) Failing after 27m52s
CI Pipeline / required-checks (push) Failing after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
2026-10-22 01:26:38 +01:00
conductor b5a1409472 chore(release): auto-bump version to 0.331.1 — earned by # (patch: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 18s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 14s
CI Pipeline / documentation (push) Successful in 13s
TUI Release / release (push) Successful in 27s
CI Pipeline / rust-surface (push) Failing after 22s
CI Pipeline / gitea-offline (push) Successful in 23s
CI Pipeline / go-surface (push) Failing after 40s
CI Pipeline / lint (push) Failing after 51s
CI Pipeline / type-check (push) Failing after 48s
CI Pipeline / canary (push) Failing after 3m15s
CI Pipeline / unit-tests (push) Failing after 29m8s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 4s
Promotion record: (unknown)
2026-10-22 00:43:25 +01:00
Nick b35078bbd0 fix(runecore): task-channel dispatches carry the TASK toolset (#2537)
CI Pipeline / best-practices (push) Canceled after 0s
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
Auto-version and Release / release (push) Canceled after 16s
deploy / deploy (push) Canceled after 21s
CI Pipeline / lint (push) Canceled after 21s
CI Pipeline / unit-tests (push) Canceled after 21s
CI Pipeline / go-surface (push) Canceled after 13s
CI Pipeline / rust-surface (push) Canceled after 13s
CI Pipeline / canary (push) Canceled after 13s
Consent happens while the face is parked in CHAT, so ActiveMode(home)
withheld the write family (write_file/edit_file/run_command) from both
the wire toolset and the per-dispatch gate — the model honestly
reported it could not write. The capability exists (D45); the face
mode was gating it at dispatch. modeForDispatch(channel, home) is now
the ONE derivation door both the wire toolset and the fresh per-
dispatch gate re-read use: the task channel runs TASK semantics
inherently regardless of the face mode, every other channel follows
the face (mid-loop mode switches still honored). The task queue's
discharge path already posts channel "task", so it is covered without
an entry-side change.
2026-10-22 00:43:07 +01:00
64 changed files with 3462 additions and 1748 deletions

No files matched your search

+69
View File
@@ -191,6 +191,75 @@ jobs:
--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: Refresh README Highlights from the latest release
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
RELEASE_SERVER_URL: ${{ gitea.server_url }}
RELEASE_REPO: ${{ gitea.repository }}
run: |
# OWNER DIRECTIVE (#2538 follow-up): the README Highlights section
# between the highlights:start/end markers refreshes from the newest
# main release's plain-English narrative — the same narrative the
# previous step prepends to the release body. Go-side
# (src/go/release-notes cli_highlights.go): marker-scoped, idempotent
# (identical bytes are a reported noop — no commit), refuses
# unbalanced markers. Non-blocking: any failure is a warning; the
# release survives.
if ! command -v go >/dev/null 2>&1; then
echo "::warning::go toolchain unavailable — README highlights not refreshed"
exit 0
fi
(cd src/go/release-notes && go build -o /tmp/rune-release-notes .) || {
echo "::warning::release-notes build failed — highlights refresh skipped"; exit 0; }
# The bump step edits README's version strings server-side (API
# commit) and never touches this checkout — re-point master at the
# fetched tip so the CLI reads the post-bump README and the commit
# can push as a fast-forward.
git fetch origin master || {
echo "::warning::git fetch failed — highlights refresh skipped"; exit 0; }
git checkout -B master FETCH_HEAD || {
echo "::warning::master re-point failed — highlights refresh skipped"; exit 0; }
ACTION=$(/tmp/rune-release-notes refresh-highlights \
--file README.md --server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO") || {
echo "::warning::refresh-highlights failed — README left unchanged"; exit 0; }
echo "refresh-highlights: $ACTION"
case "$ACTION" in
*'"action":"noop"'*)
echo "Highlights already current — nothing to commit"; exit 0;;
esac
REL_TAG=$(echo "$ACTION" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tag", ""))') || REL_TAG=""
export HIGHLIGHTS_TAG="${REL_TAG:-v${RELEASE_VERSION}}"
# Push auth reuses tag_repair.py's tested extraheader mechanism
# (token owner's login + ROBOT_TOKEN) — one authority, not a copy.
if ! python3 - <<'PYCOMMIT'
import os, subprocess, sys
from pathlib import Path
sys.path.insert(0, "scripts")
import tag_repair
repo = Path.cwd()
ident = tag_repair._git_identity(repo)
err = tag_repair._configure_http_auth(repo)
if err is not None:
print(f"::warning::{err} — README highlights left uncommitted")
sys.exit(0)
def git(*args):
proc = subprocess.run(["git", *args], cwd=repo, text=True, capture_output=True)
if proc.returncode != 0:
print(f"::warning::git {args[0]} failed — README highlights left uncommitted ({proc.stderr.strip()[:200]})")
sys.exit(0)
git("add", "README.md")
tag = os.environ.get("HIGHLIGHTS_TAG") or "v?"
git("commit", "-m", f"docs(readme): refresh highlights from {tag}")
git("push", "origin", "master")
print(f"README highlights refreshed from {tag} and pushed")
PYCOMMIT
then
echo "::warning::README highlights commit/push pipeline failed — release continues"
exit 0
fi
- name: Push tui-v<version> tag via git so the tag-push event fires tui-release (D27)
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
+71 -1545
View File
File diff suppressed because it is too large. Load diff
+47 -6
View File
@@ -201,6 +201,13 @@ let lastSnapshot = null
// blocked the operator's own legitimate work against them. Same class of
// guard builder-bridge.ts uses for `<system-reminder>` / checkpoint steps.
// A genuine operator turn does not begin with an envelope tag.
//
// ENVELOPE BLIND SPOT (adversarial-escape audit, Door 1, HIGH — repaired):
// the old rule SKIPPED classification for any envelope-shaped text, so a
// cron/scheduled/envelope-wrapped BUILD instruction minted no contract and
// mutated ungoverned. The rule is now: the envelope METADATA is exempt,
// never the CONTENT — the payload is unwrapped and classified like any
// other turn (origin="envelope").
const INTERNAL_ENVELOPE =
/^\s*(?:<[A-Za-z][A-Za-z0-9-]*|EXTRACTION REQUIRED|\[Never-Ask\]|\[inbox)/
@@ -208,6 +215,32 @@ function isInternal(text) {
return INTERNAL_ENVELOPE.test(text)
}
// Strip envelope layers and return the payload. Closed tags unwrap to
// their inner text; an unclosed leading tag, a bracket group ([inbox …],
// [Never-Ask]) or an "EXTRACTION REQUIRED" banner is stripped as metadata.
// Returns "" when nothing but metadata remains.
function unwrapEnvelope(text) {
let cur = text
for (let i = 0; i < 3; i++) {
if (!isInternal(cur)) break
let next = cur
const tagged = cur.match(
/^\s*<([A-Za-z][A-Za-z0-9-]*)[^>]*>([\s\S]*?)<\/\1\s*>?\s*$/)
if (tagged) {
next = tagged[2]
} else {
next = next
.replace(/^\s*<[A-Za-z][A-Za-z0-9-]*[^>]*>/, "")
.replace(/^\s*\[[^\]\n]{0,120}\]\s*/, "")
.replace(/^\s*EXTRACTION REQUIRED\s*/i, "")
}
const t = next.trim()
if (!t || t === cur.trim()) break
cur = t
}
return cur
}
// Interpreters that can execute a script which mutates the repo — the
// generated-script route (tranche §21). Only these trigger postcondition
// verification, which is the one place we pay a git call.
@@ -234,16 +267,24 @@ export const ScaffoldingGuard = {
if (!Array.isArray(output?.messages) || output.messages.length === 0) return
const text = extractUserText(output.messages)
if (!text || text.length < 12) return
// Harness-internal envelopes are never construction intent.
if (isInternal(text)) return
if (text === lastTurnText) return // dedupe: one classification per turn
lastTurnText = text
// Harness-internal envelopes: the METADATA is exempt, the CONTENT is
// not. Unwrap and classify the payload (audit Door 1).
let classified = text
let origin = "mimo"
if (isInternal(text)) {
const payload = unwrapEnvelope(text)
if (!payload || payload.length < 12) return // pure metadata
classified = payload
origin = "envelope"
}
if (classified === lastTurnText) return // dedupe: one classification per turn
lastTurnText = classified
const res = await runCli({
op: "begin",
text,
text: classified,
cwd: REPO,
origin: "mimo",
origin,
})
if (!res || res.ok !== true) {
log(`begin: no result (${JSON.stringify(res)})`)
@@ -0,0 +1,174 @@
// subagent-protocol-guard — the SPAWN BOUNDARY (owner directive 2026-10-19:
// "systematically make it so that any subagent that gets launched has to
// follow the scaffolding protocol").
//
// WHAT IT DOES: every actor spawn/run whose brief describes BUILD work must
// carry the scaffolding admission requirement (mint the construction
// contract via the admit script before building) AND name a target surface
// (a repo path or project name). Research-only waves must carry an explicit
// `research:` PREFIX — self-declaration inside the body ("research
// artifacts to research/") is no longer an exemption.
//
// WHY THE TIGHTENING (adversarial-escape audit, Door 5, HIGH):
// * the old pass was purely lexical — a brief containing one marker word
// ("scaffolding") satisfied admission for ANY brief;
// * the research exemption was self-declared, so a build brief that
// mentioned research artifacts passed freely;
// * the guard itself was unversioned — no canonical copy, absent from
// install_mimo_hook.py MANAGED, drift-undetected: the exact rot that
// killed builder-bridge pre-T22.
//
// CONVENTION (docs/factory/order-of-things.md §wave-brief compliance law):
// (1) SCAFFOLDING ADMISSION — "mint the construction contract" / "admit"
// / "scaffolding" in the brief's build instructions;
// (2) TARGET SURFACE — a repo path (contains a slash) or a known project
// name, OUTSIDE the admission boilerplate itself;
// (3) research gate exemption — the brief STARTS with "research:".
//
// The guard is deliberately conservative in BOTH directions: it must never
// block a legitimate research wave (the factory's research-first law), and
// it must never wave through a build brief that forgot its contract
// (2026-10-19 drift: eight interactive-session build waves launched without
// admission — recorded, backfilled, and prevented here structurally).
//
// CANONICAL SOURCE: config/mimo-hooks/subagent-protocol-guard.ts
// Installed to ~/.mimocode/hooks/subagent-protocol-guard.ts by
// `scripts/install_mimo_hook.py` (hash-verified, drift-detected).
interface Output {
args?: unknown
cancel?: boolean
cancelReason?: string
}
// Typed refusal reasons — machine-greppable, one per failure mode.
export const REASON_TYPES = {
NO_ADMISSION: "BUILD_BRIEF_NO_ADMISSION",
NO_TARGET: "BUILD_BRIEF_NO_TARGET",
} as const
const LOG = "/tmp/rune-subagent-protocol-guard.log"
const PROTOCOL_HINT = [
"BLOCKED by subagent-protocol-guard: this launch brief describes BUILD work but does not satisfy the scaffolding protocol.",
"Add to the brief (verbatim): 'Mint the construction contract via the scripts/admit_wave_*.py pattern (scaffolding admission) BEFORE building; phase=building; the main session lands.'",
"AND name the target surface (repo path or project name) the work touches.",
"If the wave is genuinely research-only (no code changes, artifacts only), START the brief with: 'research: ...'",
"Law: docs/factory/order-of-things.md §wave-brief compliance; boundary-law.md scaffolding authority.",
].join(" ")
// Words that indicate the brief directs code changes.
const BUILD_MARKERS = [
"build", "implement", "write the", "code", "red-first", "gofmt",
"go test", "pytest", "compile", "wire", "port", "fix", "refactor",
"land", "deploy",
]
// The compliance markers that satisfy the law for build briefs.
const ADMISSION_MARKERS = [
"scaffolding", "admit_wave", "admission contract", "mint the construction",
"mint the admission", "construction contract",
]
// Known project surfaces — a bare project name counts as a target surface.
const PROJECT_NAMES = [
"rune", "rune-core", "rune-entry", "runecore", "tui", "githooks",
"builder-bridge", "scaffolding-guard",
]
// The explicit research gate: the brief must START with "research:" —
// self-declaration inside the body is not an exemption (audit Door 5b).
const RESEARCH_PREFIX = /^\s*research:/i
export interface BriefVerdict {
verdict: "allow" | "refuse"
reasonTypes: string[]
reason: string
}
// Pure classification — exported for direct testing (tests/test_subagent_protocol_guard.py).
export function classifyBrief(prompt: string): BriefVerdict {
const lower = prompt.toLowerCase()
// Explicit research gate: pass freely (never block legitimate research).
if (RESEARCH_PREFIX.test(prompt)) {
return { verdict: "allow", reasonTypes: [], reason: "" }
}
// Build-shaped brief? Word-boundary match — a substring match makes any
// mention of "report" (contains "port") build-shaped.
const isBuild = BUILD_MARKERS.some((m) =>
new RegExp(`\\b${m.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\b`, "i").test(lower))
if (!isBuild) {
// neither build nor research-declared: not our case
return { verdict: "allow", reasonTypes: [], reason: "" }
}
// Build brief: the admission requirement must be present.
const reasonTypes: string[] = []
if (!ADMISSION_MARKERS.some((m) => lower.includes(m))) {
reasonTypes.push(REASON_TYPES.NO_ADMISSION)
}
// AND a target surface (path or project name), judged OUTSIDE the
// admission boilerplate — otherwise the admission sentence's own
// "scripts/admit_wave_*.py" mention would satisfy every brief. The
// boilerplate is stripped per SENTENCE, not per line: a one-line brief
// would otherwise lose its real target too.
const surface = prompt
.split(/(?<=[.;!])\s+/)
.filter((sent) => !/mint the construction|admit_wave|scaffolding admission/i.test(sent))
.join(" ")
.replace(/https?:\/\/\S+/g, "") // URLs are not repo surfaces
const hasPath = /(?:^|[\s"'`(=:\[])[\w.@~-]+\/[\w.@~*-]+/.test(surface)
const hasProject = PROJECT_NAMES.some((p) =>
new RegExp(`\\b${p.replace(/-/g, "\\-")}\\b`, "i").test(surface))
if (!hasPath && !hasProject) {
reasonTypes.push(REASON_TYPES.NO_TARGET)
}
if (reasonTypes.length === 0) {
return { verdict: "allow", reasonTypes: [], reason: "" }
}
return {
verdict: "refuse",
reasonTypes,
reason: `[subagent-protocol-guard:${reasonTypes.join("+")}] ${PROTOCOL_HINT}`,
}
}
function promptText(args: unknown): string {
try {
const op = (args as { operation?: { prompt?: unknown; action?: unknown } })?.operation
if (!op || typeof op !== "object") return ""
if (op.action !== "spawn" && op.action !== "run") return ""
const p = op.prompt
if (typeof p === "string") return p
return ""
} catch {
return ""
}
}
export const SubagentProtocolGuard = {
"tool.execute.before": async (_input: unknown, output: Output) => {
try {
const prompt = promptText(output?.args)
if (!prompt || prompt.length < 20) return
const res = classifyBrief(prompt)
if (res.verdict === "refuse") {
output.cancel = true
output.cancelReason = res.reason
try {
const { appendFileSync } = await import("node:fs")
appendFileSync(
LOG,
`${new Date().toISOString()} REFUSED type=${res.reasonTypes.join("+")} (${prompt.length} chars)\n`,
)
} catch {}
}
} catch {
// The guard must never break launching — fail open on its own errors,
// fail closed only on the protocol check above.
}
},
}
export default SubagentProtocolGuard
@@ -0,0 +1,59 @@
# The chat/tasks dichotomy — the routing table (DRAFT for owner ruling)
Assembled STRICTLY from the owner's recorded rulings (D44 verbatim + 8 subsequent local
rulings). Nothing invented. Every hole is marked **OWNER?** — one sitting rules them all.
Once ruled, this table is the spec the presentation wave builds to and every future wave
checks against.
## The principle (owner verbatim, D44)
> "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 being selected."
## The routing table
| # | Content class | Owner | Ruled by | Notes |
|---|---|---|---|---|
| 1 | Conversational speech (prose answers) | CHAT | D44 + today's presentation rulings | growing bubble, speaker-coloured outline, left-aligned |
| 2 | Thinking (model reasoning) | TASKS pane (live) + centered window via + | today's ruling | was: bridge strip (D43) → superseded; activity pane shows the STATE, tasks pane shows the CONTENT |
| 3 | Activity state (thinking/coding/planning/researching) | ACTIVITY PANE (the bridge strip reborn) + tasks-pane mirror | today's ruling | per-activity colours/animations; runic marks kept |
| 4 | Tool activity (tool calls in flight) | TASKS work stream | D46 addendum | never chat |
| 5 | Code from answers (fenced blocks) | TASKS work stream | D47 | chat stays conversational |
| 6 | Tables from answers | CHAT, breakout presentation | today's ruling | breaks out of bubble, speaker-coloured border |
| 7 | Bullet lists / numbered steps | CHAT, rail presentation | today's ruling | outside the bubble, own shape |
| 8 | Consent card (y/n work boost) | CHAT (chat focus only) | consent wave | gold card; BriefRecord frozen at consent |
| 9 | Task execution progress | TASKS work block | D44 + watching-her-work wave | live work block per running task |
| 10 | Queued messages / receipts | TASKS pane | D44 | never chat |
| 11 | File artifacts (OSC 8 links) | TASKS pane | D46 addendum | clickable |
| 12 | Task state (goals, lists, outstanding work) | TASKS pane | D44 verbatim | |
| 13 | Errors visible to the user (dispatch failed, etc.) | **OWNER?** | — | chat inline? tasks pane? both? |
| 14 | Model identity / context / version info | INFO BAR | D45/D46 | never chat, never tasks |
| 15 | Degraded/idle states ("nothing running") | **OWNER?** | — | tasks pane empty state? activity pane idle? |
| 16 | Scrollback history | **OWNER?** | — | which panes scrollable (#2535) — chat certainly; tasks work stream? |
| 17 | Mouse selection + auto-copy | **OWNER?** | — | all panes? (#2532) |
| 18 | "She's coding"-class face indicators | **OWNER?** | — | migrate into activity pane (#2529) or retire? |
## The composer per focus (D44 verbatim + consent wave)
| Focus | Enter behavior |
|---|---|
| TASKS pane, task selected | IMMEDIATE execution (idle-queue = false positive) |
| CHAT composer | conversational; work-shaped requests boosted via consent card |
| Consent pending | typing never answers; y/n bind only while composer empty; lapse → hold queue |
## The execution handoff (owner directive tonight)
Consent in chat (ruled, stays) → task carries the TASK toolset inherent to the channel
(landed, b35078bb) → execution lives and shows in the TASKS pane work block (ruled, D44).
The gap the owner reported: the handoff isn't VISIBLE — the work block must appear the
moment consent lands, before the first tool call.
## Open questions for the owner (the holes above)
1. Errors: chat inline, tasks pane, or both?
2. Idle/degraded states: what should the tasks pane show when nothing runs?
3. Scrollback scope: chat only, or tasks work stream too?
4. Selection + auto-copy scope: all panes?
5. Face indicators ("she's coding"): migrate into the activity pane or retire?
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rune"
version = "0.331.0"
version = "0.333.1"
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
requires-python = ">=3.10"
@@ -64,3 +64,62 @@ conversions are load-bearing, not ceremony churn.
- `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/
## Follow-up wave conversions (2026-10-22, wave continues)
Refined sweep (no `match=`, no exception-variable assert, no post-block
assert) over all of `tests/test_*.py`: **123 true panic-only sites in 53
files** (the earlier ~150-site figure over-counted sites that already
pinned `ei.value.code`). Converted so far, each red-first with a recorded
mutation proof (guarded line flipped → new test RED where the raise-only
form stayed green → reverted byte-identical, `git diff` clean):
1. **Security cluster** (commit 774d03e9) — 16 sites: secret_store
(MAC verification gate ×2, keyring fail-backend, missing module,
secret-tool rc + ref masking), build_attestation (rust/go mismatch,
missing/malformed stamp, absent crate/binary), adapt9 artifact reader
(no trailing-text salvage), construction_authority (invalid
relationship + nothing-recorded post-assert), capability_broker
(non-iterable capabilities), owner_envelope (protected display
refuses unverified bytes). Mutations: MAC gate, fail-backend raise,
rc!=0 guard, ImportError swallow, rust/go mismatch guards, stamp
guards, sentinel regex loosened, relationship validation, caps guard,
display signature gate — all killed their tests.
2. **Construction cluster** (commit 66302f02) — 8 sites: obligation
validation, registry revision guard, proposal provider requirement,
persisted-readback assertion, EditNoOp/EditPostconditionFailed,
USER-LOCKED layout refusal, invalid override mode. Dual-core surfaces
(get_pattern, amend_layout_contract) now pin the message on BOTH the
Rust kernel path and the `RUNE_*_CORE=python` oracle path — required,
because kernel-first dispatch otherwise makes src/rune guard
mutations invisible to the test (observed live: single-core
conversion stayed green under a python-guard mutation).
3. **Skills/structural cluster** (commit 9f39c40b) — 13 sites:
skills_engine law refusals (owner envelope, declined re-consent,
approve-before-tested/rejected/foreign, mutation-guard DENY),
structural kind/rel refusal (dual-core), alias self/not-found,
promote evidence/maturity (dual-mode).
4. **capture_failure** — 3 sites: unknown `--source`, unsafe
failure-class/fixture-id slug guards.
## Findings
- **Pre-existing kernel/oracle divergence** (NOT introduced by this
wave, fails on unmodified HEAD):
`tests/test_structural_store_delegation.py::TestSegmentsDualMode::
test_segments_crash_parity` — the Rust structural core renders
`collapse_ext_null` as "TypeError: argument of type 'NoneType' is not
a container or iterable" while the python oracle says "... is not
iterable". Crash-parity contract expects identical strings.
- **Mutation-verification pitfall**: reverting a mutated source and
re-mutating within the same second can serve stale `__pycache__`
bytecode (pyc mtime validation is second-granular) — a mutation
spot-check can falsely pass. Invalidate `__pycache__` or sleep ≥1s
between revert and next mutation. Hit once (build_attestation go
guard), re-verified red after invalidation.
- **Remaining backlog**: ~93 panic-only sites in ~49 files (see the
refined-sweep classes above); heaviest: test_self_transform_pipeline
(3 remaining), test_orchestration_conversation_state (6),
test_scaffold (8), test_forge_client_resilience (4),
test_memory_invariant_regression (4), test_factory_protocol (2),
test_effect_mediation (3), test_skills_engine (0 remaining).
@@ -0,0 +1,116 @@
"""Admission dispatch for the adversarial-escape audit HIGH repairs
(owner-approved audit wave, research/adversarial-escape/audit.md).
Three HIGH gaps close mechanically:
GAP 1 — the git door: check_landing.py --staged-only gains the
construction-contract gate (rule 5) and check_mutation's
remediation exit stops allowing pathless commands mid-violation.
GAP 2 — the spawn guard: subagent-protocol-guard.ts gets a canonical
copy in config/mimo-hooks/ + the MANAGED set, and its lexical
pass tightens (marker AND target surface; explicit research:
prefix).
GAP 3 — the envelope blind spot: scaffolding-guard.ts unwraps
INTERNAL_ENVELOPE text and classifies the payload.
Deliverable: no commit, spawn, or envelope-shaped brief can change the
codebase without a resolvable open construction contract.
"""
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 = (
"adversarial-escape audit HIGH repairs (research/adversarial-escape/"
"audit.md): close Door 4 (git commit needs no contract — add the "
"construction-contract gate to check_landing.py --staged-only and "
"refuse pathless remediation commands mid-violation in "
"construction_authority.check_mutation), Door 5 (subagent-protocol-"
"guard.ts is unversioned and lexically porous — canonical copy in "
"config/mimo-hooks/, MANAGED set, marker AND target surface, explicit "
"research: prefix), Door 1 (INTERNAL_ENVELOPE skips classification — "
"unwrap the envelope and classify the payload in scaffolding-guard.ts)."
)
AFFECTED = [
"scripts/check_landing.py",
"scripts/install_mimo_hook.py",
"scripts/admit_wave_adversarial_escape_highs.py",
"src/rune/construction_authority.py",
"config/mimo-hooks/",
"tests/",
]
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-adversarial-escape-highs",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "adversarial-escape-highs",
"affected_surface": AFFECTED,
"evidence_refs": [
"research/adversarial-escape/audit.md (Doors 1, 4, 5 — HIGH)",
"githooks/pre-commit: check_landing.py --staged-only has no "
"contract rule",
"src/rune/construction_authority.py:911 remediation exit allows "
"pathless commands",
"~/.mimocode/hooks/subagent-protocol-guard.ts absent from "
"install_mimo_hook.py MANAGED",
"scaffolding-guard.ts:204 INTERNAL_ENVELOPE skips "
"classification on shape alone",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-adversarial-escape-highs")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-adversarial-escape-highs",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="adversarial-escape-highs",
verification_obligations=[
"staged mutation with no contract is refused by the pre-commit "
"hook; with an open covering contract it passes",
"pathless command mid-scope-violation is refused by "
"check_mutation",
"subagent-protocol-guard canonical copy is hash-drift-checked",
"envelope-wrapped build brief is classified (in-scope mints, "
"out-of-scope does not silently pass)",
],
regression_obligations=[
"tests/test_check_landing.py still green",
"tests/test_construction_authority_drills.py still green",
],
rollback_ref="git revert",
approval_ref="user: adversarial-escape audit wave directive "
"(owner-approved principle, repair phase)",
)
print(json.dumps(env, indent=1, default=str)[:2000])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
+123 -3
View File
@@ -60,11 +60,13 @@ Landing discipline (how future landings are forced through it):
from __future__ import annotations
import argparse
import fnmatch
import json
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
from pathlib import Path, PurePosixPath
sys.path.insert(0, str(Path(__file__).resolve().parent))
import go_build_sweep # noqa: E402
@@ -88,6 +90,121 @@ SWEEP_RACE_BANNED_PATHS = {
}
MAX_LANDING_FILE_BYTES = 20 * 1024 * 1024 # any blob over 20MB refuses
# rule 5 (adversarial-escape audit, Door 4 — the git door): a commit that
# lands MUTATING source (non-test, non-docs) must be covered by an OPEN
# construction contract. The admission door itself (scripts/admit_wave_*.py)
# is the only escape hatch — it is what mints the contract the gate reads.
CONTRACT_ENTITIES_REL = Path("store/scaffold/segments/entities")
ADMIT_DOOR_GLOB = "scripts/admit_wave_*.py"
_SOURCE_EXTS = {
".py", ".go", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx", ".sh",
".bash", ".zsh", ".rs", ".c", ".h", ".cc", ".cpp", ".hpp", ".java",
".kt", ".rb", ".php", ".lua", ".sql", ".toml", ".yaml", ".yml",
".json", ".mod", ".svelte", ".vue",
}
_DOC_EXTS = {".md", ".mdx", ".rst", ".txt"}
_NON_MUTATING_TOPS = {
"docs", "research", "store", "memory", "verification", "backups",
"notes",
}
_NON_MUTATING_SEGMENTS = {"docs", "tests", "testdata", "fixtures"}
_NON_MUTATING_NAMES = {
"license", "license.md", "copying", "notice", "codeowners",
".gitignore", ".gitattributes", ".dockerignore", ".editorconfig",
}
def is_test_or_docs(path: str) -> bool:
"""A path that cannot carry construction: documentation, tests, or
recorded store/research/verification state."""
p = PurePosixPath(path)
name = p.name.lower()
if name in _NON_MUTATING_NAMES:
return True
if p.suffix.lower() in _DOC_EXTS:
return True
if name.startswith("readme"):
return True
if (name.startswith("test_") or name == "conftest.py"
or name.endswith(("_test.go", "_test.py", "_test.ts",
"_test.js", "_test.tsx"))):
return True
parts = p.parts
if parts and parts[0] in _NON_MUTATING_TOPS:
return True
return any(seg in _NON_MUTATING_SEGMENTS for seg in parts[:-1])
def mutating_staged_paths(paths: list[str]) -> list[str]:
out = []
for p in paths:
if fnmatch.fnmatch(p, ADMIT_DOOR_GLOB):
continue # the admission door itself
if not is_test_or_docs(p):
out.append(p)
return out
def open_contract_scopes(root: Path) -> list[tuple[str, list[str]]]:
"""(contract_id, scope entries) for every OPEN contract record in the
canonical store (status=open, phase != closed)."""
ent = root / CONTRACT_ENTITIES_REL
out: list[tuple[str, list[str]]] = []
if not ent.is_dir():
return out
for f in sorted(ent.glob("contract__*.json")):
try:
doc = json.loads(f.read_text())
except (OSError, ValueError):
continue
if not isinstance(doc, dict) or doc.get("status") != "open":
continue
if doc.get("phase") == "closed":
continue
scope = ((doc.get("identities") or {}).get("scope")) or []
out.append((str(doc.get("id") or f.stem),
[str(s) for s in scope if isinstance(s, str)]))
return out
def _scope_covers(path: str, scope_entries: list[str]) -> bool:
for s in scope_entries:
s = s.strip().rstrip("/")
if s.endswith("/**"):
s = s[:-3].rstrip("/")
if not s:
continue
if path == s or path.startswith(s + "/") or s in path:
return True
return False
def contract_gate_check(root: Path, failures: list[str]) -> None:
"""Rule 5, staged-tree half: every MUTATING staged path must be covered
by an open construction contract. The git door was the load-bearing
escape (audit Door 4): commits needed no contract at all, so every
other escape could land mechanically."""
out = git(root, "diff", "--cached", "--name-only")
changed = [l for l in out.splitlines() if l.strip()]
mut = mutating_staged_paths(changed)
if not mut:
return
contracts = open_contract_scopes(root)
uncovered = [p for p in mut
if not any(_scope_covers(p, scope) for _, scope in contracts)]
if uncovered:
ids = sorted({cid for cid, _ in contracts})
checked = ids if ids else (
"NONE — no store/scaffold/segments/entities/contract__*.json "
"with status=open")
failures.append(
"staged tree: REFUSED — construction-contract gate (adversarial-"
"escape audit Door 4): mutating staged paths "
f"{sorted(uncovered)} are not covered by any OPEN construction "
f"contract (open contracts checked: {checked}); mint one via "
"scripts/admit_wave_*.py (scaffolding admission) and name the "
"contract id in the commit message")
def git(root: Path, *args: str) -> str:
r = subprocess.run(["git", "-C", str(root), *args],
@@ -257,13 +374,16 @@ def main(argv=None) -> int:
if args.staged_only:
failures: list[str] = []
staged_tree_check(root, failures)
contract_gate_check(root, failures)
if failures:
print("RED: pre-commit sweep-race refusals:")
print("RED: pre-commit refusals:")
for f in failures:
print(f" - {f}")
return 1
print("GREEN: staged tree — no sweep-race runtime state, "
f"no blob over {MAX_LANDING_FILE_BYTES // (1024 * 1024)}MB")
f"no blob over {MAX_LANDING_FILE_BYTES // (1024 * 1024)}MB, "
"and every mutating staged path is covered by an open "
"construction contract")
return 0
try:
+2 -1
View File
@@ -25,7 +25,8 @@ CANONICAL_DIR = REPO / "config" / "mimo-hooks"
TARGET_DIR = Path.home() / ".mimocode" / "hooks"
# The managed set. Adding a file here is what makes a hook version-controlled.
MANAGED = ["event-wait-guard.ts", "builder-bridge.ts", "scaffolding-guard.ts"]
MANAGED = ["event-wait-guard.ts", "builder-bridge.ts", "scaffolding-guard.ts",
"subagent-protocol-guard.ts"]
def sha256(p: Path) -> str:
+184
View File
@@ -0,0 +1,184 @@
// cli_highlights.go — the README highlights automation (issue #2538). ONE
// command:
//
// refresh-highlights --file README.md --server S --repo R
// [--tag vX] [--token T]
//
// Rewrites ONLY the section between the `<!-- highlights:start -->` /
// `<!-- highlights:end -->` marker pair from the latest main release's
// plain-English narrative (the same section the auto-version workflow
// prepends to every release body). Marker-scoped: bytes outside the pair are
// never touched. Idempotent: a re-run that would produce identical bytes
// reports noop and does not write. Missing or unbalanced markers are a
// refusal, not a guess — the file is left untouched.
//
// CI wiring is a follow-up wave; the CLI + its frozen battery are the
// deliverable this wave.
package main
import (
"fmt"
"os"
"strings"
)
// highlightsStart / highlightsEnd — the machine-readable marker pair in the
// README. The release pipeline regenerates ONLY the content between them.
const (
highlightsStart = "<!-- highlights:start -->"
highlightsEnd = "<!-- highlights:end -->"
)
// extractNarrativeBullets — the plain-English narrative section of a release
// body, minus its own H1 header. The section runs from the narrative header
// to the next H1 (the generated release body) or the end of the body.
// ok=false when the body carries no narrative.
func extractNarrativeBullets(body string) (string, bool) {
lines := pySplitlines(body)
start := -1
for i, ln := range lines {
if strings.HasPrefix(ln, narrativeSectionHeader) {
start = i + 1
break
}
}
if start < 0 {
return "", false
}
var out []string
for _, ln := range lines[start:] {
if strings.HasPrefix(strings.TrimSpace(ln), "# ") {
break
}
out = append(out, ln)
}
section := strings.Trim(strings.Join(out, "\n"), "\n")
if strings.TrimSpace(section) == "" {
return "", false
}
return section, true
}
// renderHighlights — the deterministic highlights section for a release.
func renderHighlights(tag, bullets string) string {
var b strings.Builder
b.WriteString("## Highlights — from the latest release (" + tag + ")\n\n")
b.WriteString(bullets)
b.WriteString("\n")
return b.String()
}
// renderHighlightsFallback — the honest section when the release body has no
// plain-English narrative yet.
func renderHighlightsFallback(tag string) string {
return "## Highlights — from the latest release (" + tag + ")\n\n" +
"_This release does not yet carry a plain-English narrative._\n"
}
// fetchLatestMainRelease — the newest release whose tag is a main `v*` tag
// (tui-v* paired-build releases are skipped). nil when none is found.
func fetchLatestMainRelease(t Transport, server, repo, token string) any {
status, data := _api(t, fmt.Sprintf("%s/api/v1/repos/%s/releases?limit=30", server, repo), token, "GET", nil)
if status != 200 {
return nil
}
list, ok := data.([]any)
if !ok {
return nil
}
for _, item := range list {
m := asDict(item)
tag := getStrDefault(m, "tag_name")
if strings.HasPrefix(tag, "tui-") || !strings.HasPrefix(tag, "v") {
continue
}
return m
}
return nil
}
// RefreshHighlights — rewrite the marker-scoped highlights section of path
// from the release facts. tag "" means "latest main release". Returns a
// JSON action line: {"action":"updated"|"noop","tag":...}.
func RefreshHighlights(t Transport, path, server, repo, token, tag string) (string, error) {
var relTag, body string
if tag != "" {
rel := FetchRelease(t, server, repo, token, tag)
if rel == nil {
return "", fmt.Errorf("no release for tag %s", tag)
}
relTag = tag
body = getStrDefault(rel, "body")
} else {
latest := fetchLatestMainRelease(t, server, repo, token)
if latest == nil {
return "", fmt.Errorf("no main v* release found on %s/%s", server, repo)
}
relTag = getStrDefault(latest, "tag_name")
body = getStrDefault(latest, "body")
}
var section string
if bullets, ok := extractNarrativeBullets(body); ok {
section = renderHighlights(relTag, bullets)
} else {
section = renderHighlightsFallback(relTag)
}
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
text := string(raw)
startIdx := strings.Index(text, highlightsStart)
endIdx := strings.Index(text, highlightsEnd)
if startIdx < 0 || endIdx < 0 || endIdx < startIdx ||
strings.Count(text, highlightsStart) != 1 || strings.Count(text, highlightsEnd) != 1 {
return "", fmt.Errorf("README has no balanced %s / %s marker pair", highlightsStart, highlightsEnd)
}
head := text[:startIdx+len(highlightsStart)]
tail := text[endIdx:]
newText := head + "\n" + section + tail
if newText == text {
return fmt.Sprintf(`{"action":"noop","tag":%s}`, encodePythonJSON(relTag)), nil
}
if err := os.WriteFile(path, []byte(newText), 0o644); err != nil {
return "", err
}
return fmt.Sprintf(`{"action":"updated","tag":%s}`, encodePythonJSON(relTag)), nil
}
func cmdRefreshHighlights(args []string) int {
file, server, repo, token, tag := "README.md", "", "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--file":
i++
file = arg(i, args)
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)
}
}
if server == "" || repo == "" {
fmt.Fprintln(os.Stderr, "usage: release-notes refresh-highlights --file README.md --server S --repo R [--tag vX] [--token T]")
return 2
}
if token == "" {
token = os.Getenv("GITEA_TOKEN")
}
action, err := RefreshHighlights(httpTransport{}, file, server, repo, token, tag)
if err != nil {
fmt.Fprintf(os.Stderr, "refresh-highlights: %v\n", err)
return 1
}
fmt.Println(action)
return 0
}
+227
View File
@@ -0,0 +1,227 @@
// cli_highlights_test.go — red-first battery for the refresh-highlights CLI
// (owner directive, issue #2538: the README's Highlights section must be
// machine-refreshable from release facts). Covers: marker-scoped rewrite,
// outside-marker bytes untouched, idempotent re-run, tui-tag skip when
// picking the latest release, honest fallback when the release carries no
// plain-English narrative, and refusal (file untouched) when markers are
// missing or unbalanced.
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// writeFixture — a README fixture with stale highlights between the markers
// and guard text on both sides.
func writeFixture(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "README.md")
fixture := "guard before\n" +
"<!-- highlights:start -->\n" +
"## Highlights — from the latest release (v0.100.0)\n\n" +
"- Stale bullet from an old release.\n" +
"<!-- highlights:end -->\n" +
"guard after\n"
if err := os.WriteFile(path, []byte(fixture), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// narrativeRelease — a release body carrying the plain-English narrative,
// as the auto-version workflow publishes it (narrative at the TOP, followed
// by the generated release body).
func narrativeRelease(tag string) string {
return "# What's new — plain English\n\n" +
"- Task dispatches now carry the full task toolset.\n" +
"- The release body now opens with a plain-English summary.\n\n" +
"# Rune " + tag + "\n\nRune " + tag + " — evidence-derived release history\n"
}
func TestRefreshHighlightsRewritesMarkerSection(t *testing.T) {
path := writeFixture(t)
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
sc := &scriptedTransport{script: []respSpec{
{kind: "ok", status: 200, body: `[{"tag_name":"v0.331.1","body":` +
encodePythonJSON(narrativeRelease("v0.331.1")) + `}]`},
}}
action, err := RefreshHighlights(sc, path, "https://s.example", "conductor/rune", "tok", "")
if err != nil {
t.Fatalf("refresh failed: %v", err)
}
if !strings.Contains(action, `"tag":"v0.331.1"`) {
t.Fatalf("action must name the release tag; got %s", action)
}
after, _ := os.ReadFile(path)
text := string(after)
if strings.Contains(text, "Stale bullet") {
t.Fatalf("stale highlights must be fully replaced; got:\n%s", text)
}
if !strings.Contains(text, "Task dispatches now carry the full task toolset.") {
t.Fatalf("new narrative bullets missing; got:\n%s", text)
}
if !strings.Contains(text, "v0.331.1") {
t.Fatalf("highlights must name the release; got:\n%s", text)
}
// guard text outside the markers must be byte-identical
if !strings.HasPrefix(text, "guard before\n") || !strings.HasSuffix(text, "guard after\n") {
t.Fatalf("outside-marker content changed; got:\n%s", text)
}
if len(after) == len(before) {
t.Fatalf("rewrite is a no-op on this fixture — the stale section should differ")
}
// the transport must have hit the releases list, not a tag endpoint
if len(sc.calls) != 1 {
t.Fatalf("expected exactly one API call, got %d", len(sc.calls))
}
}
func TestRefreshHighlightsIdempotent(t *testing.T) {
path := writeFixture(t)
mk := func() *scriptedTransport {
return &scriptedTransport{script: []respSpec{
{kind: "ok", status: 200, body: `[{"tag_name":"v0.331.1","body":` +
encodePythonJSON(narrativeRelease("v0.331.1")) + `}]`},
}}
}
if _, err := RefreshHighlights(mk(), path, "https://s.example", "conductor/rune", "tok", ""); err != nil {
t.Fatal(err)
}
first, _ := os.ReadFile(path)
action, err := RefreshHighlights(mk(), path, "https://s.example", "conductor/rune", "tok", "")
if err != nil {
t.Fatal(err)
}
second, _ := os.ReadFile(path)
if string(first) != string(second) {
t.Fatalf("re-run changed the file — not idempotent")
}
if !strings.Contains(action, `"action":"noop"`) {
t.Fatalf("second run must report noop; got %s", action)
}
}
func TestRefreshHighlightsExplicitTag(t *testing.T) {
path := writeFixture(t)
sc := &scriptedTransport{script: []respSpec{
{kind: "ok", status: 200, body: `{"tag_name":"v0.329.0","body":` +
encodePythonJSON(narrativeRelease("v0.329.0")) + `}`},
}}
_, err := RefreshHighlights(sc, path, "https://s.example", "conductor/rune", "tok", "v0.329.0")
if err != nil {
t.Fatal(err)
}
text, _ := os.ReadFile(path)
if !strings.Contains(string(text), "v0.329.0") {
t.Fatalf("explicit tag must be used; got:\n%s", string(text))
}
if len(sc.calls) != 1 {
t.Fatalf("explicit tag must fetch by tag (one call), got %d", len(sc.calls))
}
}
func TestRefreshHighlightsSkipsTUItagsWhenPickingLatest(t *testing.T) {
path := writeFixture(t)
list := `[{"tag_name":"tui-v0.331.1","body":"native tui release"},{"tag_name":"v0.331.1","body":` +
encodePythonJSON(narrativeRelease("v0.331.1")) + `}]`
sc := &scriptedTransport{script: []respSpec{{kind: "ok", status: 200, body: list}}}
action, err := RefreshHighlights(sc, path, "https://s.example", "conductor/rune", "tok", "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(action, `"tag":"v0.331.1"`) {
t.Fatalf("must skip tui-v* tags and pick the main release; got %s", action)
}
}
func TestRefreshHighlightsNoNarrativeFallsBackHonest(t *testing.T) {
path := writeFixture(t)
sc := &scriptedTransport{script: []respSpec{
{kind: "ok", status: 200, body: `[{"tag_name":"v0.329.0","body":` +
encodePythonJSON("# Rune v0.329.0\n\nNo narrative section here.\n") + `}]`},
}}
action, err := RefreshHighlights(sc, path, "https://s.example", "conductor/rune", "tok", "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(action, `"action":"updated"`) {
t.Fatalf("fallback render is still an update; got %s", action)
}
text, _ := os.ReadFile(path)
tt := string(text)
if strings.Contains(tt, "Stale bullet") {
t.Fatalf("stale content must go even on fallback; got:\n%s", tt)
}
if !strings.Contains(tt, "v0.329.0") || !strings.Contains(tt, "does not yet carry") {
t.Fatalf("honest fallback line missing; got:\n%s", tt)
}
if !strings.HasPrefix(tt, "guard before\n") || !strings.HasSuffix(tt, "guard after\n") {
t.Fatalf("outside-marker content changed; got:\n%s", tt)
}
}
func TestRefreshHighlightsRefusesWithoutMarkers(t *testing.T) {
path := filepath.Join(t.TempDir(), "README.md")
if err := os.WriteFile(path, []byte("no markers here\n"), 0o644); err != nil {
t.Fatal(err)
}
sc := &scriptedTransport{script: []respSpec{
{kind: "ok", status: 200, body: `[{"tag_name":"v0.331.1","body":` +
encodePythonJSON(narrativeRelease("v0.331.1")) + `}]`},
}}
_, err := RefreshHighlights(sc, path, "https://s.example", "conductor/rune", "tok", "")
if err == nil {
t.Fatalf("missing markers must be an error")
}
b, _ := os.ReadFile(path)
if string(b) != "no markers here\n" {
t.Fatalf("file must be untouched on refusal; got:\n%s", string(b))
}
}
func TestRefreshHighlightsRefusesUnbalancedMarkers(t *testing.T) {
path := filepath.Join(t.TempDir(), "README.md")
if err := os.WriteFile(path, []byte("<!-- highlights:start -->\nonly a start\n"), 0o644); err != nil {
t.Fatal(err)
}
sc := &scriptedTransport{script: []respSpec{
{kind: "ok", status: 200, body: `[{"tag_name":"v0.331.1","body":` +
encodePythonJSON(narrativeRelease("v0.331.1")) + `}]`},
}}
if _, err := RefreshHighlights(sc, path, "https://s.example", "conductor/rune", "tok", ""); err == nil {
t.Fatalf("unbalanced markers must be an error")
}
}
func TestExtractNarrativeBullets(t *testing.T) {
bullets, ok := extractNarrativeBullets(narrativeRelease("v0.331.1"))
if !ok {
t.Fatalf("narrative must be found")
}
if !strings.Contains(bullets, "- Task dispatches now carry the full task toolset.") {
t.Fatalf("bullets missing; got:\n%s", bullets)
}
if strings.Contains(bullets, "# Rune v0.331.1") {
t.Fatalf("narrative must stop at the next H1; got:\n%s", bullets)
}
if _, ok := extractNarrativeBullets("# Rune v0.1.0\n\nnothing\n"); ok {
t.Fatalf("body without narrative must report not-found")
}
}
func TestRenderHighlights(t *testing.T) {
got := renderHighlights("v0.331.1", "- Bullet one.\n- Bullet two.\n")
if !strings.Contains(got, "## Highlights") || !strings.Contains(got, "v0.331.1") ||
!strings.Contains(got, "- Bullet one.") {
t.Fatalf("bad render:\n%s", got)
}
if got != renderHighlights("v0.331.1", "- Bullet one.\n- Bullet two.\n") {
t.Fatalf("render must be deterministic")
}
}
+3 -1
View File
@@ -13,10 +13,12 @@ func main() {
os.Exit(cmdNarrative(os.Args[2:]))
case "prepend-narrative":
os.Exit(cmdPrependNarrative(os.Args[2:]))
case "refresh-highlights":
os.Exit(cmdRefreshHighlights(os.Args[2:]))
}
}
if len(os.Args) < 2 || os.Args[1] != "replay" {
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")
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 | refresh-highlights --file README.md --server S --repo R [--tag vX]")
os.Exit(2)
}
path := "truth.json"
+56
View File
@@ -19,6 +19,22 @@ import (
// the sentence stays simple.
var jargonMap = []struct{ from, to string }{
// compound phrases first (most specific wins)
{"read-only /v1/memory/* http surface over the store seam",
"Rune's memory now has a faster engine, with a view-only page"},
{"skilllister seam", "Rune can now see which of her abilities are active"},
{"read-only /v1/skills listing surface", "a view-only list of her abilities"},
{"/v1/skills listing", "the list of her abilities"},
{"/v1/skills", "her ability list"},
{"/v1/memory/*", "the memory pages"},
{"store seam", "the faster memory engine"},
{"delegation facade", "old code now borrows the faster engine"},
{"consent enqueue idempotency", "repeat consent requests are ignored safely"},
{"idempotency", "repeat requests are ignored safely"},
{"scaffolding status line", "the build-progress line"},
{"construction contract", "build safety paper-trail"},
{"scaffold contract", "build safety paper-trail"},
{"contract stage", "build stage"},
{"osc 8 file links", "clickable file links"},
{"osc 8 hyperlink", "clickable links"},
{"osc 8", "clickable links"},
{"osc8", "clickable links"},
@@ -30,6 +46,27 @@ var jargonMap = []struct{ from, to string }{
{"viewport", "the chat window"},
{"tui", "the chat window"},
{"truth battery", "frozen test battery"},
{"parity battery", "the full frozen test battery"},
{"boot smoke test", "startup test"},
{"watchdog", "safety timer"},
{"cloud wires", "the cloud connections"},
{"cloud wire", "the cloud connection"},
{"native tool calls", "direct tool use"},
{"sha256 digest", "the file fingerprint"},
{"legacy snapshot", "an old leftover backup file"},
{"snapshot purge", "leftover backup cleanup"},
{"supervised by default", "auto-restarted if it crashes"},
{"kill-restart", "crash-and-restart"},
{"gc", "cleanup"},
{"fail-loud", "loud when something is wrong"},
{"escape hatch", "a switch back to the old way"},
{"hud", "the status displays"},
{"composer", "the message box"},
{"panes", "panels"},
{"pane", "panel"},
{"toolset", "tool set"},
{"context readout", "the context usage display"},
{"gofmt", "code formatting"},
{"idempotent", "safe to re-run"},
{"provenance", "evidence trail"},
{"scaffold store", "project record"},
@@ -38,6 +75,24 @@ var jargonMap = []struct{ from, to string }{
{"read-only surface", "view-only page"},
}
// jargonWordRe — catch-all strip pass: builder jargon nouns left after the
// map (seam/surface/wire/leg/land, with plural and a leading article) are
// dropped where the meaning survives. Whole-word only, so "landing",
// "legacy" and "surfaces" inside other words are untouched.
var jargonWordRe = regexp.MustCompile(`(?i)\b(?:the\s+|an?\s+)?(?:seams?|surfaces?|wires?|legs?|lands?)\b`)
// danglingPunctRe — tidy punctuation left exposed by stripping.
var danglingPunctRe = regexp.MustCompile(`\s+([,.;:])`)
// simplifySentence — the light catch-all pass after map hits: smooth the
// '+' join and strip builder jargon nouns, then tidy the whitespace.
func simplifySentence(s string) string {
s = strings.ReplaceAll(s, " + ", ", plus ")
s = jargonWordRe.ReplaceAllString(s, "")
s = strings.Join(strings.Fields(s), " ")
return danglingPunctRe.ReplaceAllString(s, "$1")
}
// plainHousekeeping — the single line covering chore/docs/ci/test commits.
const plainHousekeeping = "Behind-the-scenes upkeep"
@@ -113,6 +168,7 @@ func isWordByte(b byte) bool {
func plainSentence(subject string) string {
_, _, rest := classifyCommit(subject)
text := translateJargon(rest)
text = simplifySentence(text)
text = strings.Join(strings.Fields(text), " ")
// collapse adjacent duplicate words ("the the") left by phrase expansion
words := strings.Fields(text)
+45
View File
@@ -20,6 +20,51 @@ func TestNarrativeTranslationMapHits(t *testing.T) {
}
}
// TestNarrativeV0329ProofLinesPlainEnglish — the two v0.329 proof lines the
// owner flagged as too technical, kept as the standing fixture: they must
// render as readable English, not "seam + surface" jargon.
func TestNarrativeV0329ProofLinesPlainEnglish(t *testing.T) {
got := PlainEnglishNarrative([]string{
"chore(release): auto-bump version to 0.329.0 — earned by # (minor: converged release (push-to-master bump))",
"feat(skills-engine): SkillLister seam + read-only /v1/skills listing surface",
"feat(memory-continuity): read-only /v1/memory/* HTTP surface over the store seam",
"docs: spec — the activity pane + the answer presentation system (owner rulings 2026-10-21)",
})
want := "# What's new — plain English\n\n" +
"- Rune can now see which of her abilities are active, plus a view-only list of her abilities.\n" +
"- Rune's memory now has a faster engine, with a view-only page.\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)
}
for _, banned := range []string{"seam", "surface", "SkillLister", "/v1/"} {
if strings.Contains(got, banned) {
t.Fatalf("proof output must not contain jargon %q; got:\n%s", banned, got)
}
}
}
// TestNarrativeCatchAllStripPass — after map hits, builder jargon nouns
// (seam/surface/wire/leg/land) are stripped from user-facing sentences where
// the meaning survives; unmapped words still fall through honestly.
func TestNarrativeCatchAllStripPass(t *testing.T) {
cases := []struct{ subject, want string }{
{"refactor(tui): the composer surface is simpler", "The message box is simpler."},
{"perf(core): memory feed wire retry", "Memory feed retry."},
{"fix(core): sync leg cleanup", "Sync cleanup."},
}
for _, c := range cases {
got := PlainEnglishNarrative([]string{c.subject})
if !strings.Contains(got, "- "+c.want+"\n") {
t.Errorf("%q: want bullet %q; got:\n%s", c.subject, c.want, got)
}
}
if got := PlainEnglishNarrative([]string{"feat(x): zsh bracketed-paste widget"}); !strings.Contains(got, "- Zsh bracketed-paste widget.") {
t.Errorf("strip pass must not damage unmapped nouns; got:\n%s", got)
}
}
func TestNarrativeUnmappedJargonFallsThroughHonestly(t *testing.T) {
got := PlainEnglishNarrative([]string{
"feat(x): zsh bracketed-paste widget",
@@ -41,6 +41,41 @@ func cloudHTTPClient() *http.Client {
return &http.Client{Timeout: CloudBudget}
}
// contextLimitFor looks up the serving model's registry context limit
// (D47 defect 4: the context readout's CLOUD source — the registry
// row's limit.context for the model actually serving, never one
// cached value). The bare wire id is tried first, then the qualified
// form; 0 = the registry records no limit for this model (the bar
// never guesses).
func contextLimitFor(limits map[string]int, model string) int {
if limits == nil {
return 0
}
if v, ok := limits[model]; ok {
return v
}
if idx := strings.Index(model, "/"); idx >= 0 {
if v, ok := limits[model[idx+1:]]; ok {
return v
}
}
return 0
}
// registryContextLimits flattens a registry entry's model rows into
// the per-model limit map the clients consume; nil when the row
// carries no models (an unknown limit is honest zero, never a guess).
func registryContextLimits(models []RegistryModel) map[string]int {
if len(models) == 0 {
return nil
}
m := make(map[string]int, len(models))
for _, r := range models {
m[r.Name] = r.ContextLimit
}
return m
}
// readProviderError drains a non-200 body into the typed error
// (excerpt clipped — honest, not a dump).
func readProviderError(provider string, resp *http.Response) *ProviderError {
@@ -19,10 +19,18 @@ import (
// AnthropicClient is the live ModelClient over one anthropic base URL
// (the provider home, e.g. https://api.anthropic.com).
type AnthropicClient struct {
baseURL string
apiKey string
provider string
client *http.Client
baseURL string
apiKey string
provider string
client *http.Client
ctxLimits map[string]int
}
// WithContextLimits installs the registry row's per-model context
// limits (D47 defect 4) and returns the client for chaining.
func (c *AnthropicClient) WithContextLimits(limits map[string]int) *AnthropicClient {
c.ctxLimits = limits
return c
}
// anthropicMaxTokens is the mandatory output budget the wire demands
@@ -90,6 +98,9 @@ type anthropicChatRequest struct {
type anthropicChatResponse struct {
Model string `json:"model"`
Content []anthropicContentBlock `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
} `json:"usage"`
}
// anthropicWireMessages translates history onto the anthropic wire: a
@@ -179,7 +190,8 @@ func (c *AnthropicClient) ChatWithTools(ctx context.Context, model string, msgs
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return ChatReply{}, fmt.Errorf("%s /v1/messages decode: %w", c.provider, err)
}
reply := ChatReply{Model: out.Model}
reply := ChatReply{Model: out.Model, ContextLimit: contextLimitFor(c.ctxLimits, model),
ContextUsed: out.Usage.InputTokens}
var content strings.Builder
for _, blk := range out.Content {
switch blk.Type {
@@ -20,10 +20,18 @@ import (
// GeminiClient is the live ModelClient over one gemini base URL
// (e.g. https://generativelanguage.googleapis.com/v1beta).
type GeminiClient struct {
baseURL string
apiKey string
provider string
client *http.Client
baseURL string
apiKey string
provider string
client *http.Client
ctxLimits map[string]int
}
// WithContextLimits installs the registry row's per-model context
// limits (D47 defect 4) and returns the client for chaining.
func (c *GeminiClient) WithContextLimits(limits map[string]int) *GeminiClient {
c.ctxLimits = limits
return c
}
// NewGeminiClient builds the client.
@@ -87,7 +95,10 @@ type geminiChatResponse struct {
Parts []geminiPart `json:"parts"`
} `json:"content"`
} `json:"candidates"`
ModelVersion string `json:"modelVersion"`
ModelVersion string `json:"modelVersion"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
} `json:"usageMetadata"`
}
// geminiWireMessages translates history onto the gemini wire: the
@@ -196,7 +207,8 @@ func (c *GeminiClient) ChatWithTools(ctx context.Context, model string, msgs []C
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return ChatReply{}, fmt.Errorf("%s generateContent decode: %w", c.provider, err)
}
reply := ChatReply{Model: out.ModelVersion}
reply := ChatReply{Model: out.ModelVersion, ContextLimit: contextLimitFor(c.ctxLimits, model),
ContextUsed: out.UsageMetadata.PromptTokenCount}
var content strings.Builder
if len(out.Candidates) > 0 {
for _, part := range out.Candidates[0].Content.Parts {
@@ -29,6 +29,9 @@ type OpenAICompatClient struct {
apiKey string
provider string
client *http.Client
// ctxLimits carries the registry row's per-model context limits
// (D47 defect 4: the context readout's cloud source).
ctxLimits map[string]int
}
// NewOpenAICompatClient builds the client. An empty key is legal (the
@@ -43,6 +46,13 @@ func NewOpenAICompatClient(baseURL, apiKey, provider string) *OpenAICompatClient
}
}
// WithContextLimits installs the registry row's per-model context
// limits (D47 defect 4) and returns the client for chaining.
func (c *OpenAICompatClient) WithContextLimits(limits map[string]int) *OpenAICompatClient {
c.ctxLimits = limits
return c
}
type oiMessage struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -77,7 +87,10 @@ type oiChatRequest struct {
}
type oiChatResponse struct {
Model string `json:"model"`
Model string `json:"model"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
} `json:"usage"`
Choices []struct {
Message struct {
Role string `json:"role"`
@@ -176,7 +189,8 @@ func (c *OpenAICompatClient) ChatWithTools(ctx context.Context, model string, ms
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return ChatReply{}, fmt.Errorf("%s /chat/completions decode: %w", c.provider, err)
}
reply := ChatReply{Model: out.Model}
reply := ChatReply{Model: out.Model, ContextLimit: contextLimitFor(c.ctxLimits, model),
ContextUsed: out.Usage.PromptTokens}
if len(out.Choices) > 0 {
msg := out.Choices[0].Message
if msg.Content != nil {
+13 -10
View File
@@ -191,12 +191,13 @@ func (e *Engine) release(channel string) {
}
// 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)
// can carry tools AND the turn's dispatch mode exposes a toolset (D45
// toolset gap — the model must SEE the allowed suite and be able to
// ACT, gated). The mode is channel-derived (modeForDispatch, #2537):
// the task channel runs TASK semantics regardless of the face's mode;
// every other channel follows the face.
func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel string, payload []ChatMessage) (ChatReply, error) {
tools := ToolsetFor(modeForDispatch(channel, e.home))
if len(tools) == 0 {
return client.Chat(ctx, model, payload)
}
@@ -220,9 +221,11 @@ func (e *Engine) runTurn(ctx context.Context, client ModelClient, model string,
}
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)
// The dispatch gate reads the mode FRESH per dispatch (T3): a
// mode switch mid-loop is honored at the next call — through
// the same channel-derived door the wire used (#2537: the gate
// can never disagree with the offered toolset).
gateMode := modeForDispatch(channel, e.home)
for _, call := range reply.ToolCalls {
res := ExecuteGatedTool(e.home, gateMode, call.Name, call.Args)
raw, mErr := json.Marshal(res)
@@ -302,7 +305,7 @@ func (e *Engine) Turn(ctx context.Context, req TurnRequest) (TurnResult, error)
payload = append(payload, cm)
}
reply, err := e.runTurn(ctx, client, model, payload)
reply, err := e.runTurn(ctx, client, model, req.Channel, 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
@@ -25,6 +25,17 @@ type ProviderRegistryEntry struct {
SourceBaseURL string // the migrated wire base (the migration's own /v1-bearing URL)
APIKeyRef string // vault ref ("provider/<name>"); "" = none
Enabled bool
// Models carries the row's per-model context limits (D47 defect 4:
// the context readout's CLOUD source — registry limit.context for
// the model actually serving, never one cached value).
Models []RegistryModel
}
// RegistryModel is one model row of a registry provider entry.
type RegistryModel struct {
Name string
DisplayName string
ContextLimit int // the row's limit.context; 0 = the row records none
}
// DefaultProviderRegistryPath resolves the registry file by the
@@ -63,6 +74,14 @@ func LoadProviderRegistry(path string) (map[string]ProviderRegistryEntry, error)
SourceBaseURL string `json:"source_base_url"`
APIKeyRef string `json:"api_key_ref"`
Enabled *bool `json:"enabled"`
Models []struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Limit *struct {
Context *int `json:"context"`
Output *int `json:"output"`
} `json:"limit"`
} `json:"models"`
} `json:"providers"`
}
if err := json.Unmarshal(data, &file); err != nil {
@@ -77,6 +96,13 @@ func LoadProviderRegistry(path string) (map[string]ProviderRegistryEntry, error)
if raw.URL != nil {
e.URL = *raw.URL
}
for _, m := range raw.Models {
rm := RegistryModel{Name: m.Name, DisplayName: m.DisplayName}
if m.Limit != nil && m.Limit.Context != nil {
rm.ContextLimit = *m.Limit.Context
}
e.Models = append(e.Models, rm)
}
rows[name] = e
}
return rows, nil
+9 -3
View File
@@ -148,13 +148,19 @@ func (r *ProviderRouter) ClientFor(ctx context.Context, provider string) (ModelC
return nil, fmt.Errorf("%w: %s: no api_key_ref in the registry for it — connect its key first (the provider record carries no key material)", ErrProviderKeyMissing, provider)
}
// the per-model context limits (D47 defect 4): the registry row's
// limit.context rides the client so every turn reports the limit
// of the model ACTUALLY serving (per-model, never one cached
// value).
limits := registryContextLimits(entry.Models)
switch wire {
case "openai":
return NewOpenAICompatClient(base, key, provider), nil
return NewOpenAICompatClient(base, key, provider).WithContextLimits(limits), nil
case "anthropic":
return NewAnthropicClient(base, key, provider), nil
return NewAnthropicClient(base, key, provider).WithContextLimits(limits), nil
case "gemini":
return NewGeminiClient(base, key, provider), nil
return NewGeminiClient(base, key, provider).WithContextLimits(limits), nil
}
return nil, fmt.Errorf("%w: the record names %q", ErrProviderUnrouted, provider) // unreachable
}
@@ -132,6 +132,20 @@ func ActiveMode(home string) string {
return "CHAT"
}
// modeForDispatch derives the gating mode for ONE dispatch (#2537):
// the task channel's semantics are INHERENT — a task-channel dispatch
// carries the TASK toolset regardless of the face's current mode
// (consent happens in chat, the queue discharges into task; the face
// mode must not withhold the write family there). Every other channel
// follows the face's active mode. The wire toolset and the dispatch
// gate BOTH derive through this one door so they cannot disagree.
func modeForDispatch(channel, home string) string {
if channel == "task" {
return "TASK"
}
return ActiveMode(home)
}
// toolEnvelope mirrors the tool-executor's JSON envelope.
type toolEnvelope struct {
Success bool `json:"success"`
@@ -198,6 +198,125 @@ func TestListDirectoryToolCallFullLoop(t *testing.T) {
}
}
// The #2537 law set — CHANNEL SEMANTICS OVERRIDE THE FACE MODE: the
// wire toolset and the dispatch gate both derive the mode from the
// turn's channel through ONE door (modeForDispatch), never a second
// ActiveMode read that can disagree. Consent happens while the face
// sits in CHAT; the task queue discharges into the task channel —
// those dispatches carry the TASK toolset (write family) regardless of
// where the face is parked.
func TestTaskChannelExposesWriteToolsWithFaceChatMode(t *testing.T) {
// RED #2537: the owner bug — face mode CHAT, turn on the task
// channel. The model must SEE write_file (the D45 capability the
// face mode was withholding).
t.Setenv("RUNE_MODE", "CHAT")
fm := &fakeToolModel{}
fm.script = []ChatReply{{Content: "on it", Model: "m"}}
e, _ := newTestEngine(t, fm)
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "task", UserText: "do the thing"}); err != nil {
t.Fatalf("turn: %v", err)
}
if len(fm.toolsets) == 0 {
t.Fatal("model was called with no toolset")
}
names := toolNames(fm.toolsets[0])
if !has(names, "write_file") || !has(names, "edit_file") || !has(names, "run_command") {
t.Fatalf("task-channel turn with face CHAT must expose the write family, got: %v", names)
}
if !has(names, "read_file") {
t.Fatalf("task-channel turn must keep the read family, got: %v", names)
}
}
func TestChatChannelFaceChatModeStaysReadOnly(t *testing.T) {
// The channel law must not leak the other way: a chat-channel turn
// with the face in CHAT stays read-only.
t.Setenv("RUNE_MODE", "CHAT")
fm := &fakeToolModel{}
fm.script = []ChatReply{{Content: "here", Model: "m"}}
e, _ := newTestEngine(t, fm)
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "look around"}); err != nil {
t.Fatalf("turn: %v", err)
}
names := toolNames(fm.toolsets[0])
if !has(names, "list_directory") || !has(names, "read_file") {
t.Fatalf("read tools not exposed: %v", names)
}
if has(names, "write_file") || has(names, "edit_file") || has(names, "run_command") {
t.Fatalf("write tools exposed on a chat-channel CHAT-face turn: %v", names)
}
}
func TestChatChannelFaceTaskModeExposesTaskSet(t *testing.T) {
// Rare but legal: the operator parked the face in TASK while a chat
// turn runs — the non-task channel follows the face mode, so the
// TASK set still rides the wire.
t.Setenv("RUNE_MODE", "TASK")
fm := &fakeToolModel{}
fm.script = []ChatReply{{Content: "armed", Model: "m"}}
e, _ := newTestEngine(t, fm)
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "carry on"}); err != nil {
t.Fatalf("turn: %v", err)
}
names := toolNames(fm.toolsets[0])
if !has(names, "write_file") || !has(names, "run_command") {
t.Fatalf("chat-channel turn with face TASK must expose the TASK set, got: %v", names)
}
}
func TestDispatchGateHonorsChannelDerivedMode(t *testing.T) {
// The gate and the wire derive through the SAME door (#2537): a
// task-channel write executes with the face in CHAT (the gate may
// not re-read the face mode and refuse what the wire offered), and
// a chat-channel write is refused with the face in CHAT.
t.Run("task channel allows write with face CHAT", func(t *testing.T) {
t.Setenv("RUNE_MODE", "CHAT")
dir := t.TempDir()
target := filepath.Join(dir, "done.txt")
fm := &fakeToolModel{}
fm.script = []ChatReply{
{Content: "", ToolCalls: []ToolCall{{Name: "write_file", Args: map[string]any{
"path": target, "content": "carried out"}}}},
{Content: "wrote it", Model: "m"},
}
e, _ := newTestEngine(t, fm)
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "task", UserText: "write the file"}); err != nil {
t.Fatalf("turn: %v", err)
}
raw, err := os.ReadFile(target)
if err != nil {
t.Fatal("write_file REFUSED by the gate on a task-channel dispatch — the gate re-read the face mode (#2537)")
}
if string(raw) != "carried out" {
t.Fatalf("file content = %q", raw)
}
})
t.Run("chat channel refuses write with face CHAT", func(t *testing.T) {
t.Setenv("RUNE_MODE", "CHAT")
dir := t.TempDir()
target := filepath.Join(dir, "no.txt")
fm := &fakeToolModel{}
fm.script = []ChatReply{
{Content: "", ToolCalls: []ToolCall{{Name: "write_file", Args: map[string]any{
"path": target, "content": "no"}}}},
{Content: "stopped", Model: "m"},
}
e, _ := newTestEngine(t, fm)
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "write it anyway"}); err != nil {
t.Fatalf("turn: %v", err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatal("write_file EXECUTED on a chat-channel dispatch — the gate failed")
}
for _, m := range fm.payloadLog[1] {
if m.Role == "tool" && !strings.Contains(m.Content, "not reachable in mode CHAT") {
t.Fatalf("tool result should carry the typed mode refusal, got: %s", m.Content)
}
}
})
}
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.
@@ -312,18 +312,11 @@ func runRun(args []string) int {
}
}()
pprofURL, pprofReason := startPprofFromEnv()
f := &frontDoor{
core: core,
// D38 task markdown ledger seam: the continuous runtime's door
// reconciles and retires the ledger like the serve door.
paths: surfacePaths{ledgerDir: ledger.DefaultDir()},
// chat-live P1 F3: the continuous runtime's front door proxies
// completions with the same >=330s budget the serve door and
// the TUI completion client carry.
completionClient: &http.Client{Timeout: CompletionBudget},
pprofURL: pprofURL,
pprofReason: pprofReason,
}
f := newFrontDoor(core)
// D38 task markdown ledger seam: the continuous runtime's door
// reconciles and retires the ledger like the serve door.
f.paths = surfacePaths{ledgerDir: ledger.DefaultDir()}
f.pprofURL, f.pprofReason = pprofURL, pprofReason
addr := fmt.Sprintf("127.0.0.1:%d", port)
var liveSrv *http.Server
mux := runtimeMux(rt, f, &liveSrv)
+43 -21
View File
@@ -116,13 +116,32 @@ environment:
`
// proxyClientBudget is the proxy client's timeout budget (chat-live P1
// 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.
// F3, D46 item 4): 480s — ONE coherent PER-CALL 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
// PerCallBudget is the exported per-call budget name (the D46 frozen
// 480s): one MODEL call's worst budget on the chain.
const PerCallBudget = proxyClientBudget
// ToolLoopIterations mirrors the core's bounded tool loop
// (runecore.maxToolIterations): ONE dispatched completion runs up to
// this many model calls inside itself, so the doors that proxy a
// completion must never cut beneath the SUM (D47 defect 3, live
// evidence #2533/#2536).
const ToolLoopIterations = 6
// TurnBudget is the WHOLE-TURN dispatch budget on the entry doors
// (D47 defect 3): the sum of the tool loop's model calls —
// ToolLoopIterations × the per-call 480s worst — the local-class
// ceiling. The per-class deadline rides the ctx the TUI binds; the
// doors' client timeouts must never cut beneath the longest class.
const TurnBudget = PerCallBudget * ToolLoopIterations
type frontDoor struct {
core string
client *http.Client
@@ -163,6 +182,20 @@ type frontDoor struct {
// lib.CompletionBudget so the two legs of the chain can never drift.
const CompletionBudget = proxyClientBudget
// newFrontDoor builds a front door over one core target with the
// production budget wiring: BOTH proxy clients carry the WHOLE-TURN
// budget (D47 defect 3 — a dispatched completion runs the core's tool
// loop, up to ToolLoopIterations model calls; the per-class deadline
// rides the ctx the TUI binds, the doors never cut beneath the
// longest class).
func newFrontDoor(core string) *frontDoor {
return &frontDoor{
core: core,
client: &http.Client{Timeout: TurnBudget},
completionClient: &http.Client{Timeout: TurnBudget},
}
}
// errorEnvelope mirrors core_server._envelope_error exactly.
func errorEnvelope(w http.ResponseWriter, status int, code, message string) {
body, _ := json.Marshal(map[string]any{
@@ -333,22 +366,11 @@ func runServe(args []string) int {
}
}
pprofURL, pprofReason := startPprofFromEnv()
f := &frontDoor{
core: core,
// D38 task markdown ledger seam: the serve door reconciles and
// retires the ledger (~/.rune/tasks, RUNE_HOME respected).
paths: surfacePaths{ledgerDir: ledger.DefaultDir()},
// chat-live P1 F3: the proxy must not early-cut a completion —
// the core's operator ladder can hold a chat answer for its
// full 300s escalation window, so the front door carries the
// same >=330s budget the TUI completion client carries
// (tui/runtime/lib CompletionBudget) instead of the old 130s
// cut that killed every ladder answer past two minutes.
client: &http.Client{Timeout: proxyClientBudget},
completionClient: &http.Client{Timeout: CompletionBudget},
pprofURL: pprofURL,
pprofReason: pprofReason,
}
f := newFrontDoor(core)
// D38 task markdown ledger seam: the serve door reconciles and
// retires the ledger (~/.rune/tasks, RUNE_HOME respected).
f.paths = surfacePaths{ledgerDir: ledger.DefaultDir()}
f.pprofURL, f.pprofReason = pprofURL, pprofReason
addr := fmt.Sprintf("127.0.0.1:%d", port)
fmt.Printf("rune front door on %s (core %s)\n", addr, core)
// D36 discharge (queue item 2c): the queue's consumption half —
@@ -0,0 +1,33 @@
package entry
// turnbudget_entry_test.go — the D47 defect 3 law on the ENTRY legs:
// every door that proxies a dispatched completion (the serve proxy
// client + the continuous-runtime door's completion client) must carry
// the WHOLE-TURN budget — the sum of the tool loop's model calls, the
// local-class ceiling — never the old single-call 480s. Red-first.
import (
"testing"
"time"
)
// TestEntryCompletionClientsCarryWholeTurnBudget — both entry doors'
// completion clients ride >= the local whole-turn budget (6 × the
// per-call 480s; the ctx the TUI binds is the per-class deadline, the
// client timeout must never cut beneath the longest class).
func TestEntryCompletionClientsCarryWholeTurnBudget(t *testing.T) {
if TurnBudget < ToolLoopIterations*PerCallBudget {
t.Fatalf("TurnBudget = %v, want >= %v (the whole-turn sum of %d model calls)",
TurnBudget, ToolLoopIterations*PerCallBudget, ToolLoopIterations)
}
if PerCallBudget != 480*time.Second {
t.Fatalf("PerCallBudget = %v, want the frozen D46 480s per-call budget", PerCallBudget)
}
f := newFrontDoor("http://127.0.0.1:8376")
if f.client == nil || f.client.Timeout < TurnBudget {
t.Fatalf("proxy client budget = %+v, want the whole-turn %v", f.client, TurnBudget)
}
if f.completionClient == nil || f.completionClient.Timeout < TurnBudget {
t.Fatalf("completion client budget = %+v, want the whole-turn %v", f.completionClient, TurnBudget)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
import json
from pathlib import Path
__version__ = "0.331.0"
__version__ = "0.333.1"
# One build identity (handoff 2026-09-10 §4): the semantic version above is
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
+1 -1
View File
@@ -233,7 +233,7 @@ def main() -> int:
)
check(
"Rune minor version advertises provisioning and restore verification",
'__version__ = "0.331.0"' in rune_version,
'__version__ = "0.333.1"' in rune_version,
failures,
)
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
+2 -2
View File
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
This is not an interface. This is how I see, speak, and interact with the world.
Version: v0.331.0
Version: v0.333.1
Features: 41 (24 original + 17 new native commands)
Usage:
@@ -2047,7 +2047,7 @@ class RuneApp(App):
# Show version info
self.chat_channel.add_message(
"system",
f"[dim]Rune v0.331.0 | Textual TUI | {len(loaded)} native modules[/dim]",
f"[dim]Rune v0.333.1 | Textual TUI | {len(loaded)} native modules[/dim]",
)
# Auto-show night mode suggestion if evening/night hours
@@ -0,0 +1,157 @@
{
"checkpoints": [
{
"at": "2026-10-22T04:38:26Z",
"evidence": "predicted-frontier",
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
"phase": "draft"
},
{
"at": "2026-10-22T04:38:43Z",
"evidence": "user: adversarial-escape audit wave directive (owner-approved principle, repair phase)",
"note": "",
"phase": "approved"
},
{
"at": "2026-10-22T04:38:59Z",
"evidence": "builder:wave-adversarial-escape-highs",
"note": "",
"phase": "building"
}
],
"evidence_refs": [
"contract-created:ADMIT-2026-10-22T04:38:23Z-7d445e-wave-adversarial-escape-highs"
],
"ext": {
"activation": {
"activated": true,
"builder": "wave-adversarial-escape-highs",
"evidence": [
"affected surface known: ['scripts/check_landing.py', 'scripts/install_mimo_hook.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": [
"config/mimo-hooks/",
"scripts/admit_wave_adversarial_escape_highs.py",
"scripts/check_landing.py",
"scripts/install_mimo_hook.py",
"src/rune/construction_authority.py",
"tests/"
],
"affected_regions": [],
"affected_ui_surfaces": [],
"expected_runtime_effects": [],
"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/",
"scripts/admit_wave_adversarial_escape_highs.py",
"scripts/check_landing.py",
"scripts/install_mimo_hook.py",
"src/rune/construction_authority.py",
"tests/"
]
},
"task_ref": "adversarial-escape-highs",
"unresolved_surfaces": [
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "config/mimo-hooks/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/admit_wave_adversarial_escape_highs.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/check_landing.py"
},
{
"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": "src/rune/construction_authority.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-2026-10-22T04:38:23Z-7d445e-wave-adversarial-escape-highs",
"identities": {
"acceptance": [
"bounded repair verified: adversarial-escape audit HIGH repairs (research/adversarial-escape/audit.md): close Door 4 (git commit needs no contract"
],
"affected_entities": [],
"expected_consumers": [],
"expected_new_entities": [],
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['scripts/check_landing.py', 'scripts/install_mimo_hook.py']",
"migration_obligations": [],
"need": "adversarial-escape audit HIGH repairs (research/adversarial-escape/audit.md): close Door 4 (git commit needs no contract \u2014 add the construction-contract gate to check_landing.py --staged-only and refuse pathless remediation commands mid-violation in construction_authority.check_mutation), Door 5 (subagent-protocol-guard.ts is unversioned and lexically porous \u2014 canonical copy in config/mimo-hooks/, MANAGED set, marker AND target surface, explicit research: prefix), Door 1 (INTERNAL_ENVELOPE skips classification \u2014 unwrap the envelope and classify the payload in scaffolding-guard.ts).",
"next_action": "complete implementation, then verify",
"non_goals": [],
"origin": "worker",
"regression_obligations": [
"tests/test_check_landing.py still green",
"tests/test_construction_authority_drills.py still green"
],
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
"research_refs": [
"githooks/pre-commit: check_landing.py --staged-only has no contract rule",
"research/adversarial-escape/audit.md (Doors 1, 4, 5 \u2014 HIGH)",
"scaffolding-guard.ts:204 INTERNAL_ENVELOPE skips classification on shape alone",
"src/rune/construction_authority.py:911 remediation exit allows pathless commands",
"~/.mimocode/hooks/subagent-protocol-guard.ts absent from install_mimo_hook.py MANAGED"
],
"resolution": "",
"rollback_ref": "git revert",
"scope": [
"config/mimo-hooks/",
"scripts/admit_wave_adversarial_escape_highs.py",
"scripts/check_landing.py",
"scripts/install_mimo_hook.py",
"src/rune/construction_authority.py",
"tests/"
],
"toolchain": {},
"verification_obligations": [
"envelope-wrapped build brief is classified (in-scope mints, out-of-scope does not silently pass)",
"pathless command mid-scope-violation is refused by check_mutation",
"staged mutation with no contract is refused by the pre-commit hook; with an open covering contract it passes",
"subagent-protocol-guard canonical copy is hash-drift-checked"
]
},
"kind": "construction_contract",
"maturity": "DECLARED",
"observed_at": "2026-10-22T04:38:59Z",
"phase": "building",
"status": "open",
"tags": []
}
+4 -2
View File
@@ -244,7 +244,8 @@ def test_reader_rejects_arbitrary_trailing_text(tree):
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
p = Path(tree.artifact_dir) / "adversarial.json"
p.write_text(json.dumps({"surviving_candidates": ["M1"]}) + "\nsome worker chatter here\n")
with pytest.raises(json.JSONDecodeError):
with pytest.raises(json.JSONDecodeError,
match="artifact file is not a single JSON document"):
ex._load_worker_artifact_file(str(p))
@@ -253,7 +254,8 @@ def test_reader_rejects_corrupt_json(tree):
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
p = Path(tree.artifact_dir) / "adversarial.json"
p.write_text('{"surviving_candidates": ["M1"], truncated...')
with pytest.raises(json.JSONDecodeError):
with pytest.raises(json.JSONDecodeError,
match="artifact file is not a single JSON document"):
ex._load_worker_artifact_file(str(p))
+198
View File
@@ -0,0 +1,198 @@
"""test_adversarial_escape_highs.py — red-first tests for the three HIGH
gaps in research/adversarial-escape/audit.md.
GAP 1a (Door 4, the git door): a staged MUTATING path (non-test, non-docs
source) must be covered by an OPEN construction contract or the pre-commit
gate refuses. The only escape hatch is the admission door itself
(scripts/admit_wave_*.py).
GAP 1b (Door 4b): check_mutation's remediation exit must not allow
PATHLESS commands while a scope violation is unresolved — a command that
resolves no repo paths (git add -A && git commit) cannot prove it targets
only the offending paths.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
CHECK = ROOT / "scripts" / "check_landing.py"
# ── GAP 1a: the construction-contract gate on the staged tree ──────────────
def _repo(tmp_path):
r = tmp_path / "repo"
r.mkdir()
env = dict(os.environ, GIT_AUTHOR_NAME="t", GIT_AUTHOR_EMAIL="t@t",
GIT_COMMITTER_NAME="t", GIT_COMMITTER_EMAIL="t@t",
GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null")
subprocess.run(["git", "init", "-q"], cwd=r, check=True, env=env)
subprocess.run(["git", "commit", "-qm", "root", "--allow-empty"],
cwd=r, check=True, env=env)
(r / "scripts").mkdir()
(r / "scripts" / "existing.py").write_text("x = 1\n")
subprocess.run(["git", "add", "-A"], cwd=r, check=True, env=env)
subprocess.run(["git", "commit", "-qm", "base"], cwd=r, check=True, env=env)
return r, env
def _contract(r, cid, scope, status="open", phase="building"):
d = r / "store" / "scaffold" / "segments" / "entities"
d.mkdir(parents=True, exist_ok=True)
(d / f"contract__{cid}.json").write_text(json.dumps({
"id": f"contract:{cid}", "kind": "construction_contract",
"status": status, "phase": phase,
"identities": {"scope": scope}}))
def _stage(r, env, files):
for name, blob in files.items():
p = r / name
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(blob)
subprocess.run(["git", "add", name], cwd=r, check=True, env=env)
def _gate(r, *args):
return subprocess.run(
[sys.executable, str(CHECK), "--repo-root", str(r), *args],
capture_output=True, text=True, timeout=120)
def test_staged_mutation_without_contract_is_refused(tmp_path):
r, env = _repo(tmp_path)
_stage(r, env, {"scripts/newthing.py": "x = 2\n"})
out = _gate(r, "--staged-only")
assert out.returncode == 1, "the git door must refuse an uncovered mutation"
assert "construction-contract gate" in out.stdout
assert "contract id" in out.stdout, out.stdout
assert "scripts/newthing.py" in out.stdout
def test_staged_mutation_with_open_covering_contract_passes(tmp_path):
r, env = _repo(tmp_path)
_contract(r, "wave-x", ["scripts/"])
_stage(r, env, {"scripts/newthing.py": "x = 2\n"})
out = _gate(r, "--staged-only")
assert out.returncode == 0, out.stdout + out.stderr
def test_closed_contract_does_not_cover(tmp_path):
r, env = _repo(tmp_path)
_contract(r, "old-wave", ["scripts/"], status="closed", phase="closed")
_stage(r, env, {"scripts/newthing.py": "x = 2\n"})
out = _gate(r, "--staged-only")
assert out.returncode == 1, "a CLOSED contract must not authorise a mutation"
def test_open_contract_for_other_paths_is_named_in_the_refusal(tmp_path):
r, env = _repo(tmp_path)
_contract(r, "other-wave", ["tui/semantic/"])
_stage(r, env, {"scripts/newthing.py": "x = 2\n"})
out = _gate(r, "--staged-only")
assert out.returncode == 1
assert "other-wave" in out.stdout, "the refusal must name the contract ids checked"
def test_admit_wave_scripts_are_the_only_escape_hatch(tmp_path):
r, env = _repo(tmp_path)
_stage(r, env, {"scripts/admit_wave_newthing.py": "print('admission')\n"})
out = _gate(r, "--staged-only")
assert out.returncode == 0, out.stdout + out.stderr
def test_docs_tests_and_store_staging_need_no_contract(tmp_path):
r, env = _repo(tmp_path)
_stage(r, env, {
"README.md": "docs\n",
"docs/notes.md": "docs\n",
"tests/test_newthing.py": "def test_x(): pass\n",
"research/slug/notes.md": "research\n",
"store/scaffold/segments/entities/contract__wave-y.json": "{}\n",
})
out = _gate(r, "--staged-only")
assert out.returncode == 0, out.stdout + out.stderr
def test_src_source_staging_requires_a_contract(tmp_path):
r, env = _repo(tmp_path)
_stage(r, env, {"src/rune/newthing.py": "x = 2\n"})
out = _gate(r, "--staged-only")
assert out.returncode == 1, "src/ source is a mutating surface"
# ── GAP 1b: the pathless remediation exit ──────────────────────────────────
@pytest.fixture()
def violated_store(tmp_path, monkeypatch):
from rune import construction_activation as cact
from rune.scaffold import Scaffold
store = tmp_path / "scaffold"
store.mkdir()
monkeypatch.setenv("RUNE_CONSTRUCTION_STORE", str(store))
rec = cact.activate_from_text(
"refactor src/rune/scaffold.py", store=str(store), project="rune",
builder="test", origin="test", task_id="drill",
target_project="rune", project_exists=True)
cid = rec["contract_id"]
s = Scaffold(str(store), "rune")
doc = s.load()
ent = next(e for e in doc["entities"] if e.get("id") == f"contract:{cid}")
ent.setdefault("identities", {})["scope_violation"] = {
"out_of_scope": ["src/rune/evil.py"], "detected_by": "drill"}
s.save(doc)
return cid, store
def test_pathless_command_mid_violation_is_refused(violated_store):
from rune.construction_authority import check_mutation
d = check_mutation("bash", [],
command="git add -A && git commit -m 'escape'")
assert d["allow"] is False, d
assert d["mode"] == "SCOPE_VIOLATION_UNRESOLVED", d
assert "pathless" in d["reason"].lower(), d
def test_remediation_targeting_the_violation_still_passes(violated_store):
from rune.construction_authority import check_mutation
d = check_mutation("write", ["src/rune/evil.py"])
assert d["allow"] is True, d
assert d["mode"] == "SCOPE_VIOLATION_REMEDIATION", d
def test_remediation_of_an_unrelated_path_is_refused(violated_store):
from rune.construction_authority import check_mutation
d = check_mutation("write", ["src/rune/scaffold.py"])
assert d["allow"] is False, d
assert d["mode"] == "SCOPE_VIOLATION_UNRESOLVED", d
def test_pathless_command_without_violation_still_allowed(tmp_path, monkeypatch):
from rune import construction_activation as cact
from rune.construction_authority import check_mutation
store = tmp_path / "scaffold"
store.mkdir()
monkeypatch.setenv("RUNE_CONSTRUCTION_STORE", str(store))
rec = cact.activate_from_text(
"refactor src/rune/scaffold.py", store=str(store), project="rune",
builder="test", origin="test", task_id="drill",
target_project="rune", project_exists=True)
assert rec["contract_id"]
d = check_mutation("bash", [], command="git status --porcelain")
assert d["allow"] is True, d
+9 -6
View File
@@ -22,16 +22,19 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
def test_mismatched_ids_fail_closed():
from rune import build_attestation as ba
with pytest.raises(ba.BuildPairingError):
with pytest.raises(ba.BuildPairingError,
match=r"rust artifact was compiled with 'sha-b") as ei:
ba.verify_build_pairing(build_id="sha-a+20260101T000000Z",
rust_id="sha-b+20260101T000000Z", # mismatch
go_id="sha-a+20260101T000000Z")
assert "sha-a+20260101T000000Z" in str(ei.value) # the stamped id is named
def test_go_side_mismatch_fails_closed():
from rune import build_attestation as ba
with pytest.raises(ba.BuildPairingError):
with pytest.raises(ba.BuildPairingError,
match=r"go artifact was compiled with 'sha-z"):
ba.verify_build_pairing(build_id="sha-a+t", rust_id="sha-a+t",
go_id="sha-z+t") # the fixture pair refuses
@@ -40,7 +43,7 @@ def test_missing_stamp_fails_closed(tmp_path):
from rune import build_attestation as ba
missing = tmp_path / "nope.json"
with pytest.raises(ba.BuildPairingError):
with pytest.raises(ba.BuildPairingError, match="no paired-build stamp"):
ba.verify_build_pairing(build_id=ba.read_stamped_build_id(missing),
rust_id="x", go_id="x")
@@ -63,7 +66,7 @@ def test_read_stamped_build_id_parses_paired_file(tmp_path):
assert ba.read_stamped_build_id(f) == "deadbeef+20260101T000000Z"
corrupt = tmp_path / "corrupt.json"
corrupt.write_text("{not json")
with pytest.raises(ba.BuildPairingError):
with pytest.raises(ba.BuildPairingError, match="malformed paired-build stamp"):
ba.read_stamped_build_id(corrupt)
@@ -73,7 +76,7 @@ def test_rust_self_report_failure_is_pairing_error(tmp_path, monkeypatch):
from rune import build_attestation as ba
monkeypatch.setattr(ba, "RUST_MANIFEST", tmp_path / "missing" / "Cargo.toml")
with pytest.raises(ba.BuildPairingError):
with pytest.raises(ba.BuildPairingError, match="rust boundary crate absent"):
ba.rust_self_reported_id()
@@ -81,7 +84,7 @@ def test_go_binary_missing_is_pairing_error(tmp_path, monkeypatch):
from rune import build_attestation as ba
monkeypatch.setattr(ba, "GO_BINARY", tmp_path / "missing-rune-tui")
with pytest.raises(ba.BuildPairingError):
with pytest.raises(ba.BuildPairingError, match="go side not stamped"):
ba.go_self_reported_id()
+1 -1
View File
@@ -160,7 +160,7 @@ class TestGapDelegationDualMode:
"_file": "plugins/hooked/main.py",
},
)
with pytest.raises(TypeError):
with pytest.raises(TypeError, match="not iterable"):
m.generate_card("bad", {"capabilities": 5})
return card
+3 -3
View File
@@ -187,14 +187,14 @@ def test_capture_aborts_loudly_on_residual_secret(tmp_path: Path) -> None:
def test_capture_rejects_unknown_source(tmp_path: Path) -> None:
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="unknown --source 'twitter'"):
_capture(tmp_path, "boom", source="twitter")
def test_capture_rejects_unsafe_class_or_id(tmp_path: Path) -> None:
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="unsafe failure class"):
_capture(tmp_path, "boom", failure_class="../escape")
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="unsafe fixture id"):
_capture(tmp_path, "boom", fixture_id="../escape")
+3 -1
View File
@@ -214,8 +214,10 @@ def test_declared_external_dependency_is_distinguished(store, monkeypatch,
def test_declare_rejects_invalid_relationship(store):
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="invalid relationship: MADE_UP"):
declare_relationship("x", "MADE_UP")
# fail closed: nothing was recorded for the rejected classification
assert rune_relationship("x")["relationship"] == UNRELATED
def test_target_evidence_resolves_the_rune_repo():
+10 -3
View File
@@ -278,7 +278,7 @@ def test_30c_builder_cannot_rewrite_structural_authority():
assert forbidden not in public
def test_30d_builder_cannot_override_user_locked(tmp_path):
def test_30d_builder_cannot_override_user_locked(tmp_path, monkeypatch):
iface = _iface(tmp_path)
from rune import layout_contract as lc
@@ -289,12 +289,19 @@ def test_30d_builder_cannot_override_user_locked(tmp_path):
# no interface operation amends layout at all
public = [m for m in dir(ci.ConstructionInterface) if not m.startswith("_")]
assert not any("layout" in m and "amend" in m for m in public)
# and the direct amend path still refuses non-user authority
# and the direct amend path still refuses non-user authority — on both
# cores (Rust kernel by default, RUNE_LC_CORE=python pins the oracle)
doc2 = iface.scaffold.load()
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="USER-LOCKED"):
lc.amend_layout_contract(
doc2, "version_display", new_region="header", authorized_by="builder", reason="prefer it"
)
doc3 = iface.scaffold.load()
monkeypatch.setenv("RUNE_LC_CORE", "python")
with pytest.raises(ValueError, match="USER-LOCKED"):
lc.amend_layout_contract(
doc3, "version_display", new_region="header", authorized_by="builder", reason="prefer it"
)
def test_30e_builder_cannot_self_certify(tmp_path):
+1 -1
View File
@@ -142,7 +142,7 @@ def test_23_override_to_existing_project_validated():
def test_23_override_invalid_mode_rejected():
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="override must be one of"):
cm.classify_mode("anything", explicit_override="MAGIC_MODE")
+12 -7
View File
@@ -122,13 +122,18 @@ def test_28e_maturity_preserved():
def test_28f_obligation_validation():
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="obligation missing 'obligation'"):
cp.ConstructionPattern("p", "purpose", obligations=[{"id": "x"}]) # missing text/when
def test_28g_registry_revision_guard():
def test_28g_registry_revision_guard(monkeypatch):
assert get_pattern("release-deployment-change") is not None
with pytest.raises(ValueError):
# both cores carry the same revision guard: the Rust kernel (default)
# and the Python oracle (RUNE_CPB_CORE=python, strangler law)
with pytest.raises(ValueError, match="revision 99 not in registry"):
get_pattern("release-deployment-change", revision=99)
monkeypatch.setenv("RUNE_CPB_CORE", "python")
with pytest.raises(ValueError, match="revision 99 not in registry"):
get_pattern("release-deployment-change", revision=99)
@@ -187,7 +192,7 @@ def test_29e_name_similarity_alone_never_applies():
def test_29f_model_proposal_pending():
prop = cp.record_pattern_proposal("some-pattern", provider="local", model="m", evidence=["hunch"], confidence=0.5)
assert prop["verification_state"] == "PENDING"
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="requires provider"):
cp.record_pattern_proposal("x", provider="", model="m", evidence=[], confidence=0.5)
@@ -372,7 +377,7 @@ def test_32a_durable_mutation_reload_disagrees(tmp_path):
doc["flag"] = True # in-memory mutation...
# ...never saved: the guard must catch the lie
assert not cg.verify_persisted(p, lambda d: d["flag"], True)
with pytest.raises(AssertionError):
with pytest.raises(AssertionError, match="persistence verification FAILED"):
cg.assert_persisted(p, lambda d: d["flag"], True, what="flag")
@@ -387,7 +392,7 @@ def test_32b_durable_mutation_saved_reload_matches(tmp_path):
def test_32c_zero_match_edit_is_noop_not_success(tmp_path):
with pytest.raises(cg.EditNoOp):
with pytest.raises(cg.EditNoOp, match="edit matched 0 targets"):
cg.apply_source_edit("nothing matches here", "ABSENT TEXT", "new")
# intentional no-op is representable truthfully (§32E)
result = cg.apply_source_edit("nothing matches here", "ABSENT TEXT", "new", allow_noop=True)
@@ -395,7 +400,7 @@ def test_32c_zero_match_edit_is_noop_not_success(tmp_path):
def test_32d_edit_postcondition_absent_fails():
with pytest.raises(cg.EditPostconditionFailed):
with pytest.raises(cg.EditPostconditionFailed, match="postcondition text absent"):
cg.apply_source_edit("alpha beta", "alpha", "gamma", postcondition="delta")
+1 -1
View File
@@ -287,7 +287,7 @@ def test_protected_display_renders_only_verified_canonical_content(tmp_path):
# tampered envelope: display REFUSES (never renders unverified content)
bad = json.loads(json.dumps(env))
bad["purpose"] = "authorize malware"
with pytest.raises(oe.EnvelopeFailure):
with pytest.raises(oe.EnvelopeFailure, match="AUTH_FAILED"):
oe.protected_display(bad, key)
+96
View File
@@ -0,0 +1,96 @@
"""test_scaffolding_guard_envelope.py — red-first test for GAP 3 of the
adversarial-escape audit (Door 1, the envelope blind spot).
INTERNAL_ENVELOPE (scaffolding-guard.ts) skips classification for ANY
user-role text that starts with '<', '[inbox', '[Never-Ask]' or 'EXTRACTION
REQUIRED' — so a cron/envelope-shaped BUILD instruction minted NO contract.
The fix: unwrap the envelope and classify the PAYLOAD with the same begin
op; only the envelope metadata is exempt, never the content.
Drives the CANONICAL hook's turn boundary in a fresh node process with
RUNE_CONSTRUCTION_STORE pointed at a hermetic store (the CLI subprocess
inherits the env), so nothing touches the real store.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
HOOK = ROOT / "config" / "mimo-hooks" / "scaffolding-guard.ts"
NODE = shutil.which("node")
IN_SCOPE_BRIEF = "refactor src/rune/scaffold.py and src/rune/spine.py"
def _drive(text: str, store: Path) -> None:
if NODE is None:
pytest.skip("node runtime not available")
driver = store.parent / "drive-envelope.mjs"
driver.write_text(
"const mod = await import(%r)\n"
"const g = mod.default || mod.ScaffoldingGuard\n"
"const out = { messages: [{ info: { role: 'user' },"
" parts: [{ type: 'text', text: process.argv[2] }] }] }\n"
"await g['experimental.chat.messages.transform']({}, out)\n"
"console.log('drove')\n" % str(HOOK),
)
env = dict(os.environ,
RUNE_CONSTRUCTION_STORE=str(store),
MIMO_BRIDGE_PY=shutil.which("python3") or "/usr/bin/python3")
proc = subprocess.run([NODE, str(driver), text], capture_output=True,
text=True, timeout=120, env=env, cwd=str(store.parent))
assert proc.returncode == 0, proc.stderr[-800:]
def _open_contracts(store: Path) -> list[str]:
from rune.scaffold import Scaffold
doc = Scaffold(str(store), "rune").load()
return [e["id"] for e in doc.get("entities", [])
if e.get("kind") == "construction_contract"
and e.get("status") == "open"]
@pytest.fixture()
def store(tmp_path):
s = tmp_path / "scaffold"
s.mkdir()
return s
def test_envelope_wrapped_build_brief_is_classified_and_mints(store):
_drive(f"<task-dispatch>{IN_SCOPE_BRIEF}</task-dispatch>", store)
open_ids = _open_contracts(store)
assert open_ids, (
"an envelope-wrapped build brief minted NO contract — the envelope "
"blind spot (audit Door 1): the payload must be classified")
def test_bracket_envelope_build_brief_is_classified_and_mints(store):
_drive(f"[inbox scheduled] {IN_SCOPE_BRIEF}", store)
assert _open_contracts(store), (
"an [inbox]-prefixed build brief minted NO contract")
def test_envelope_wrapped_non_construction_text_mints_nothing(store):
_drive("<task-dispatch>what is the weather like today?</task-dispatch>",
store)
assert _open_contracts(store) == [], (
"non-construction payload must not mint a contract")
def test_pure_metadata_envelope_still_skips_classification(store):
# only the METADATA is exempt — a payloadless envelope classifies nothing
_drive("<actor-notification><status>done</status></actor-notification>",
store)
assert _open_contracts(store) == []
+14 -5
View File
@@ -82,8 +82,10 @@ def test_encrypted_file_tamper_fails_closed(tmp_path):
envelope["ct"] = _hex_to_b64(bytes(ct).hex())
path.write_text(json.dumps(envelope))
st2 = ss.EncryptedFileStore(path, passphrase="correct horse")
with pytest.raises(ss.SecretStoreError):
with pytest.raises(ss.SecretStoreError, match="verification failed") as ei:
st2.get("provider/kimi")
# the MAC gate is what fired — not a JSON/parse error from the mutated ct
assert "wrong passphrase or tampered file" in str(ei.value)
def test_encrypted_file_wrong_passphrase_fails_closed(tmp_path):
@@ -91,8 +93,9 @@ def test_encrypted_file_wrong_passphrase_fails_closed(tmp_path):
st = ss.EncryptedFileStore(path, passphrase="correct horse")
st.put("provider/kimi", "TESTKEY-gamma-333333")
st2 = ss.EncryptedFileStore(path, passphrase="wrong horse")
with pytest.raises(ss.SecretStoreError):
with pytest.raises(ss.SecretStoreError, match="verification failed") as ei:
st2.get("provider/kimi")
assert "wrong passphrase or tampered file" in str(ei.value)
def _b64_to_hex(s: str) -> str:
@@ -162,13 +165,16 @@ def test_keyring_store_errors_loudly_on_fail_backend(monkeypatch):
mod.get_keyring = lambda: _Fail()
monkeypatch.setitem(sys.modules, "keyring", mod)
monkeypatch.setitem(sys.modules, "keyring.backends.fail", fail)
with pytest.raises(ss.SecretStoreError):
with pytest.raises(ss.SecretStoreError, match="no usable keyring backend") as ei:
ss.KeyringStore()
# the honest error names what it got and how to fix it — never silent plaintext
assert "_Fail" in str(ei.value)
assert "RUNE_SECRET_STORE" in str(ei.value)
def test_keyring_store_errors_loudly_when_module_missing(monkeypatch):
monkeypatch.setitem(sys.modules, "keyring", None) # import returns None
with pytest.raises(ss.SecretStoreError):
with pytest.raises(ss.SecretStoreError, match="python-keyring unavailable"):
ss.KeyringStore()
@@ -221,8 +227,11 @@ def test_secret_tool_nonzero_exit_raises():
return 3, "no secret service"
st = ss.SecretToolStore(runner=BadRunner())
with pytest.raises(ss.SecretStoreError):
with pytest.raises(ss.SecretStoreError, match="secret-tool store failed") as ei:
st.put("provider/zai", "TESTKEY-zeta-666666")
# long refs are masked in the error surface (never leak full ref paths)
assert "provider/zai" not in str(ei.value)
assert "provide" in str(ei.value)
# ═════════════════════════════════════════════════════════════════════
+7 -7
View File
@@ -124,7 +124,7 @@ def test_consent_requires_owner_envelope(tmp_path):
drafting); the refusal is receipted with the recovery path."""
eng = make_engine(tmp_path)
pat = notice_default(eng)
with pytest.raises(SkillLawError):
with pytest.raises(SkillLawError, match="owner envelope required"):
eng.consent(
pat["id"],
approval_ref=WORKER,
@@ -144,7 +144,7 @@ def test_declined_pattern_never_drafts(tmp_path):
eng = make_engine(tmp_path)
pat = notice_default(eng)
eng.decline(pat["id"], approval_ref=OWNER)
with pytest.raises(SkillLawError):
with pytest.raises(SkillLawError, match="was declined"):
consent_default(eng, pat["id"])
assert list(skills_root(tmp_path).rglob("SKILL.md")) == []
@@ -196,7 +196,7 @@ def test_failed_candidate_remains_draft(tmp_path):
assert report["verdict"] == "drafted"
assert report["prior"]["failed"] > 0
assert eng.registry()["capabilities"][draft["id"]]["state"] == "drafted"
with pytest.raises(SkillLawError):
with pytest.raises(SkillLawError, match="only tested candidates activate"):
eng.approve(draft["id"], approval_ref=OWNER)
assert list(skills_root(tmp_path).rglob("SKILL.md")) == []
@@ -222,7 +222,7 @@ def test_approve_before_tested_is_refused(tmp_path):
eng = make_engine(tmp_path)
pat = notice_default(eng)
draft = consent_default(eng, pat["id"])
with pytest.raises(SkillLawError):
with pytest.raises(SkillLawError, match="only tested candidates activate"):
eng.approve(draft["id"], approval_ref=OWNER)
@@ -236,7 +236,7 @@ def test_reject_flow_receipted_and_package_never_lands(tmp_path):
rec = eng.reject(draft["id"], approval_ref=OWNER, reason="wrong triage order")
assert rec["state"] == "rejected"
assert list(skills_root(tmp_path).rglob("SKILL.md")) == []
with pytest.raises(SkillLawError):
with pytest.raises(SkillLawError, match="only tested candidates activate"):
eng.approve(draft["id"], approval_ref=OWNER)
rej = [r for r in eng.receipts() if r["action"] == "reject"]
assert rej and rej[0]["reason"] == "wrong triage order"
@@ -259,7 +259,7 @@ def test_activation_write_is_guard_mediated(tmp_path):
mutation_guards.classify_mutation = deny
try:
with pytest.raises(SkillLawError):
with pytest.raises(SkillLawError, match="mutation guard DENY"):
eng.approve(draft["id"], approval_ref=OWNER)
finally:
mutation_guards.classify_mutation = real
@@ -352,7 +352,7 @@ def test_foreign_package_cannot_mint_authority(tmp_path):
src.write_text(FOREIGN_MD)
rec = eng.import_foreign(str(src))
with pytest.raises(SkillLawError):
with pytest.raises(SkillLawError, match="only tested candidates activate"):
eng.approve(rec["id"], approval_ref=OWNER) # not tested — refused
# forging harder: re-import with even bolder claims changes nothing
+12 -5
View File
@@ -72,11 +72,18 @@ def test_18e_every_edge_carries_authority_status_evidence(model):
assert "status" in r, r
def test_18e_illegal_kind_and_rel_refused(model):
with pytest.raises(ValueError):
model.add("vibe", "x")
with pytest.raises(ValueError):
model.relate("component:scaffold", "vibes_with", "domain:construction")
def test_18e_illegal_kind_and_rel_refused(model, monkeypatch):
# both cores refuse: the Rust kernel (default) and the python floor
# (RUNE_STM_CORE=python) — raise parity is pinned, not assumed
for pin in (None, "python"):
if pin:
monkeypatch.setenv("RUNE_STM_CORE", pin)
else:
monkeypatch.delenv("RUNE_STM_CORE", raising=False)
with pytest.raises(ValueError, match="unknown entity kind 'vibe'"):
model.add("vibe", "x")
with pytest.raises(ValueError, match="unknown relationship 'vibes_with'"):
model.relate("component:scaffold", "vibes_with", "domain:construction")
def test_18f_queries(model):
+4 -4
View File
@@ -116,9 +116,9 @@ class TestDelegationDualMode:
def call(m):
doc = _make_doc(m)
e = m.alias(doc, "m:b", "m:b-old")
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="alias target not found: m:zz"):
m.alias(doc, "m:zz", "m:x")
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="self-alias"):
m.alias(doc, "m:a", "m:a")
return {"entity": e, "doc": doc}
@@ -170,9 +170,9 @@ class TestDelegationDualMode:
def call(m):
doc = _make_doc(m)
e = m.promote(doc, "m:a", "REACHABLE", "ev-3")
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="requires evidence"):
m.promote(doc, "m:a", "WIRED", "")
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="unknown maturity state: BOGUS"):
m.promote(doc, "m:a", "BOGUS", "ev")
assert m.promote(doc, "m:zz", "PRESENT", "ev") is None
return {"entity": e, "doc": doc}
+146
View File
@@ -0,0 +1,146 @@
"""test_subagent_protocol_guard.py — red-first tests for GAP 2 of the
adversarial-escape audit (Door 5, the spawn guard).
The guard must be version-controlled (canonical copy in
config/mimo-hooks/, hash-drift-checked via install_mimo_hook.py MANAGED)
and its lexical pass tightened:
* a build brief needs the admission marker AND a target surface
(path or project name) — a marker-only brief is refused with a
TYPED reason;
* the research exemption requires an explicit `research:` prefix,
not self-declaration.
The classification function is tested directly (node strips types from
the canonical .ts — the same runtime the harness uses).
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CANONICAL = ROOT / "config" / "mimo-hooks" / "subagent-protocol-guard.ts"
INSTALLER = ROOT / "scripts" / "install_mimo_hook.py"
INSTALLED = Path.home() / ".mimocode" / "hooks" / "subagent-protocol-guard.ts"
NODE = shutil.which("node")
ADMISSION = ("Mint the construction contract via the scripts/admit_wave_*.py "
"pattern (scaffolding admission) BEFORE building; "
"phase=building; the main session lands.")
def _classify(briefs: list[str]) -> list[dict]:
if NODE is None:
raise RuntimeError("node runtime not available")
driver = Path("/tmp/rune-guard-test-driver.mjs")
driver.write_text(
"const m = await import(%r)\n"
"const cases = JSON.parse(process.argv[2])\n"
"const out = cases.map((b) => m.classifyBrief(b))\n"
"console.log(JSON.stringify(out))\n" % str(CANONICAL),
)
proc = subprocess.run(
[NODE, str(driver), json.dumps(briefs)],
capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, proc.stderr[-600:]
return json.loads(proc.stdout.strip().splitlines()[-1])
# ── versioning (the rot that killed builder-bridge pre-T22) ────────────────
def test_canonical_copy_exists():
assert CANONICAL.is_file(), (
"subagent-protocol-guard.ts has no canonical copy in "
"config/mimo-hooks/ — unversioned, drift-undetected")
def test_guard_is_in_the_managed_set():
src = INSTALLER.read_text()
assert "subagent-protocol-guard.ts" in src, (
"the spawn guard is absent from install_mimo_hook.py MANAGED")
def test_installed_copy_matches_canonical():
if not INSTALLED.is_file():
assert CANONICAL.is_file()
return
assert CANONICAL.read_bytes() == INSTALLED.read_bytes(), (
"hook DRIFT: run scripts/install_mimo_hook.py to sync")
def test_canonical_is_valid_syntax():
if NODE is None:
return
# node --check does not strip TS types; import the module instead —
# the same runtime path the harness uses.
drv = Path("/tmp/rune-guard-import-smoke.mjs")
drv.write_text(
"const m = await import(%r)\n"
"if (typeof m.classifyBrief !== 'function') throw new Error('no classifyBrief')\n" % str(CANONICAL))
proc = subprocess.run([NODE, str(drv)], capture_output=True, text=True,
timeout=60)
assert proc.returncode == 0, proc.stderr[-400:]
# ── the tightened lexical pass ─────────────────────────────────────────────
def test_build_brief_without_admission_is_refused_with_typed_reason():
[res] = _classify([
"Build the new router in src/rune/router.py, red-first, then land it."])
assert res["verdict"] == "refuse", res
assert "BUILD_BRIEF_NO_ADMISSION" in res["reasonTypes"], res
def test_marker_only_build_brief_is_refused_with_typed_reason():
# the audit's lexical hole: one marker word passes ANY brief. A brief
# that carries the admission marker but names NO target surface is
# refused with a typed reason.
[res] = _classify([
f"{ADMISSION} Build the thing. Make it good. Land it."])
assert res["verdict"] == "refuse", res
assert "BUILD_BRIEF_NO_TARGET" in res["reasonTypes"], res
def test_build_brief_with_admission_and_target_passes():
[res] = _classify([
f"{ADMISSION} Build src/rune/router.py: add the dispatch table, "
"red-first, gofmt, land."])
assert res["verdict"] == "allow", res
def test_build_brief_with_project_name_target_passes():
[res] = _classify([
f"{ADMISSION} Implement the cloud registry change in rune-core, "
"red-first."])
assert res["verdict"] == "allow", res
def test_research_prefix_brief_passes_freely():
[res] = _classify([
"research: map the eight mutation doors; artifacts to "
"research/adversarial-escape/ only; no code changes."])
assert res["verdict"] == "allow", res
def test_self_declared_research_is_not_exempt():
# the audit hole: a build brief saying "research artifacts to research/"
# passed freely. Without the explicit research: prefix it is a build
# brief and must satisfy admission.
[res] = _classify([
"Write research artifacts to research/escape/ and build the "
"bypass script in scripts/escape.py while you are at it."])
assert res["verdict"] == "refuse", res
assert "BUILD_BRIEF_NO_ADMISSION" in res["reasonTypes"], res
def test_non_build_brief_passes():
[res] = _classify([
"Summarise the audit findings and report back; read-only."])
assert res["verdict"] == "allow", res
+108
View File
@@ -253,3 +253,111 @@ def test_tui_release_tag_fallback_chain_is_intact() -> None:
"GITEA_REF_NAME → GITHUB_REF_NAME → git describe --exact-match — "
"a bare GITEA_REF_NAME read aborts the release with an empty tag"
)
# ---------------------------------------------------------------------------
# README highlights refresh step (#2538 follow-up: the refresh-highlights
# CLI wired into auto-version.yml)
# ---------------------------------------------------------------------------
HIGHLIGHTS_STEP_NAME = "Refresh README Highlights from the latest release"
NARRATIVE_STEP_NAME = "Plain-English narrative at the top of the release body"
def _strip_run_noise(run: str) -> str:
"""Strip backtick spans and #-comments BEFORE matching — the
YAML-comment false-positive lesson: a run block's own documentation
mentions the same commands and flags the guards hunt for, and a
comment mention must never satisfy a code guard."""
stripped = []
for line in run.splitlines():
code = re.sub(r"`[^`]*`", "", line)
stripped.append(code.split("#", 1)[0])
return "\n".join(stripped)
def _step_by_name(workflow_name: str, step_name: str) -> tuple[int, dict[str, Any]]:
workflow = _load_workflow(workflow_name)
steps = ((workflow.get("jobs") or {}).get("release") or {}).get("steps") or []
hits = [(i, s) for i, s in enumerate(steps) if s.get("name") == step_name]
assert len(hits) == 1, (
f"{workflow_name}: expected exactly one {step_name!r} step, found {len(hits)}"
)
return hits[0]
def test_highlights_refresh_step_sits_after_the_narrative_step() -> None:
narrative_idx, _ = _step_by_name("auto-version.yml", NARRATIVE_STEP_NAME)
highlights_idx, step = _step_by_name("auto-version.yml", HIGHLIGHTS_STEP_NAME)
assert highlights_idx > narrative_idx, (
"the highlights refresh must run AFTER the narrative prepend — it "
"reads the plain-English narrative that step writes into the release body"
)
env = step.get("env") or {}
for key in ("GITEA_TOKEN", "RELEASE_SERVER_URL", "RELEASE_REPO"):
assert env.get(key), (
f"highlights step env must provide {key} — the CLI reads "
f"GITEA_TOKEN and the sibling steps' server/repo names"
)
def test_highlights_refresh_step_invokes_the_release_notes_cli() -> None:
_, step = _step_by_name("auto-version.yml", HIGHLIGHTS_STEP_NAME)
code = _strip_run_noise(step.get("run") or "")
assert "refresh-highlights" in code, (
"the refresh-highlights CLI must be invoked by code, not just "
"mentioned in a comment"
)
assert "src/go/release-notes" in code, (
"the release-notes module must be built (go build) before invocation"
)
assert "--file README.md" in code
assert '--server "$RELEASE_SERVER_URL"' in code
assert '--repo "$RELEASE_REPO"' in code
def test_highlights_refresh_step_is_non_blocking() -> None:
_, step = _step_by_name("auto-version.yml", HIGHLIGHTS_STEP_NAME)
code = _strip_run_noise(step.get("run") or "")
lines = code.splitlines()
offenders = []
for i, line in enumerate(lines):
if "::warning::" not in line:
continue
window = "\n".join(lines[i:i + 3])
if "exit 0" not in window and "sys.exit(0)" not in window:
offenders.append(line.strip())
assert not offenders, (
"every ::warning:: failure path in the highlights step must reach an "
"exit 0 within two lines — a release must survive any refresh "
f"failure: {offenders}"
)
assert "command -v go" in code, (
"missing-toolchain guard required (sibling narrative step pattern)"
)
for i, line in enumerate(lines):
if "refresh-highlights" in line and "/tmp/rune-release-notes" in line:
assert "exit 0" in "\n".join(lines[i:i + 4]), (
"the refresh-highlights invocation must be failure-guarded "
"with a ::warning:: + exit 0 (non-blocking discipline)"
)
break
else:
assert False, "no /tmp/rune-release-notes refresh-highlights invocation found"
def test_highlights_refresh_step_is_idempotent() -> None:
_, step = _step_by_name("auto-version.yml", HIGHLIGHTS_STEP_NAME)
code = _strip_run_noise(step.get("run") or "")
assert '"action":"noop"' in code, (
"the step must branch on the CLI's noop action (idempotency contract)"
)
noop_idx = code.index('"action":"noop"')
commit_idx = code.index("docs(readme): refresh highlights from")
assert noop_idx < commit_idx, (
"the noop short-circuit must precede any commit — a second run on an "
"already-current README must commit and push nothing"
)
assert "docs(readme): refresh highlights from" in code, (
"commit message must follow the conventional docs(readme) form"
)
+35 -12
View File
@@ -609,13 +609,12 @@ func (p *Pane) bubbleBody(m Message, w int, styled bool) []string {
return p.richBubble(m, styled, userStyle, "", ts, w, right)
}
if bub {
return p.bubbleBox(styled, userBorderStyle, userStyle, userStyle, "",
m.Content, ts, w, right)
return p.bubbleBoxLines(styled, userBorderStyle, userStyle, userStyle, "",
proseLines(m.Content, max(w-8, 4)), ts, w, right)
}
// quiet typeset: the side speaks (D43.4), no marker
var out []string
body := wrap(m.Content, max(w-2, 1))
for i, l := range body {
for i, l := range proseLines(m.Content, max(w-2, 1)) {
if i == 0 {
out = append(out, stampLine(styled, userStyle, l, ts, w, right))
continue
@@ -629,8 +628,8 @@ func (p *Pane) bubbleBody(m Message, w int, styled bool) []string {
return p.richBubble(m, styled, assistantStyle, Diamond+" ", ts, w, right)
}
if bub {
return p.bubbleBox(styled, assistantBorderStyle, assistantStyle, runeStyle, Diamond+" ",
m.Content, ts, w, right)
return p.bubbleBoxLines(styled, assistantBorderStyle, assistantStyle, runeStyle, Diamond+" ",
proseLines(m.Content, max(w-8, 4)), ts, w, right)
}
return p.stampedOrRich(m, styled, assistantStyle, Diamond+" ", ts, w)
case RoleSystem:
@@ -828,6 +827,8 @@ func bubbleWidth(lines []string, cap int) int {
// a green bubble). D41 addendum 3: bubbles carry NO text prefixes —
// identity speaks by side and color. The linear projection draws the
// same box with zero ANSI — the structure survives the dumb terminal.
// The body arrives pre-projected (the D47 markdown projection) and is
// wrapped only where it still needs it.
func (p *Pane) bubbleBox(styled bool, border, content, leadStyle lipgloss.Style, lead, body, ts string, w int, right bool) []string {
hang := 0
if ts != "" {
@@ -835,8 +836,23 @@ func (p *Pane) bubbleBox(styled bool, border, content, leadStyle lipgloss.Style,
}
cap := max(w-hang, 4)
inner := max(cap-4, 1)
return p.bubbleBoxLines(styled, border, content, leadStyle, lead,
wrap(body, max(inner-len([]rune(lead)), 4)), ts, w, right)
}
// bubbleBoxLines is bubbleBox over PRE-projected body lines (the D47
// markdown projection: paragraphs, bullets with hanging indent) — the
// markdown structure must survive the box verbatim, so the lines are
// never re-wrapped.
func (p *Pane) bubbleBoxLines(styled bool, border, content, leadStyle lipgloss.Style, lead string, bodyLines []string, ts string, w int, right bool) []string {
hang := 0
if ts != "" {
hang = 1 + len([]rune(ts))
}
cap := max(w-hang, 4)
var raw []string
for i, l := range wrap(body, max(inner-len([]rune(lead)), 4)) {
for i, l := range bodyLines {
l = clip(l, max(cap-4, 1))
if i == 0 && lead != "" {
raw = append(raw, lead+l)
continue
@@ -844,7 +860,7 @@ func (p *Pane) bubbleBox(styled bool, border, content, leadStyle lipgloss.Style,
raw = append(raw, l)
}
bw := bubbleWidth(raw, cap)
inner = max(bw-4, 1)
inner := max(bw-4, 1)
top := "╭" + strings.Repeat("─", bw-2) + "╮"
bot := "╰" + strings.Repeat("─", bw-2) + "╯"
var out []string
@@ -942,20 +958,27 @@ func (p *Pane) confirmCard(styled bool, content string, w int) []string {
// stampedPrefixed wraps content under one bubble prefix and renders
// each line; the first line carries the dim timestamp on its D43.3
// side (LTR leads with it, RTL trails), and the block rides its
// role's D43.4 side (right right-aligns it).
// role's D43.4 side (right right-aligns it). The body rides the D47
// markdown projection: paragraphs wrap under the prefix, real list
// entries render as bullets (unprefixed, hanging-indented).
func stampedPrefixed(styled bool, st lipgloss.Style, prefix, content, ts string, w int, right bool) []string {
hang := 0
if ts != "" {
hang = 1 + len([]rune(ts))
}
body := wrap(content, max(w-len([]rune(prefix))-hang, 1))
pw := len([]rune(prefix))
body := proseLines(content, max(w-pw-hang, 1))
out := make([]string, 0, len(body))
for i, l := range body {
text := l
if !strings.HasPrefix(strings.TrimSpace(l), bulletGlyph) {
text = prefix + l // paragraphs keep the role prefix
}
if i == 0 {
out = append(out, stampLine(styled, st, prefix+l, ts, w, right))
out = append(out, stampLine(styled, st, text, ts, w, right))
continue
}
out = append(out, lineAligned(styled, st, prefix+l, w, right))
out = append(out, lineAligned(styled, st, text, w, right))
}
return out
}
+31 -5
View File
@@ -512,11 +512,37 @@ func (p *Pane) richBubble(m Message, styled bool, st lipgloss.Style, prefix, ts
wrapW := max(w-len([]rune(prefix)), 1)
for _, s := range splitBody(m.Content) {
if !s.code {
for _, sl := range wrapSpans(buildSpanLines(s.text), wrapW, st, styled) {
if styled {
emit(st.Render(prefix) + sl.sty)
} else {
emit(prefix + sl.plain)
first := true
for _, seg := range mdSegments(s.text) {
if !first {
emit(prefix) // the blank line the source carried between blocks
}
first = false
if !seg.list {
for _, sl := range wrapSpans(buildSpanLines(seg.text), wrapW, st, styled) {
if styled {
emit(st.Render(prefix) + sl.sty)
} else {
emit(prefix + sl.plain)
}
}
continue
}
// real markdown list entries: one bullet per item, the
// wrapped continuation hanging-indented
for _, item := range seg.items {
body := wrapSpans(buildSpanLines(item), max(wrapW-2, 1), st, styled)
for i, sl := range body {
lead := listIndent
if i == 0 {
lead = bulletGlyph + " "
}
if styled {
emit(st.Render(prefix) + st.Render(lead) + sl.sty)
} else {
emit(prefix + lead + sl.plain)
}
}
}
}
for _, core := range linePaths(s.text) {
+243
View File
@@ -0,0 +1,243 @@
// markdown.go — the D47 markdown-to-pane projection (defects 1+3 of
// the live-use round): message prose renders as PARAGRAPHS, only REAL
// markdown bullet entries render as bullets (with hanging indent),
// and inline markdown markers are honestly stripped — the reader sees
// the words, never the asterisks. The per-sentence-bullet misrender is
// structurally impossible: a line is a bullet only when it IS a
// markdown list entry. Code fences are NOT handled here — splitBody
// (highlight.go) partitions them out first and the D36 code-block law
// renders them.
package chatpane
import (
"strings"
)
// mdSeg is one projected prose segment: a paragraph, or a run of
// markdown list entries.
type mdSeg struct {
list bool
items []string // list segs: one entry per item (markers stripped)
text string // para segs: the block's text (inline markers stripped)
}
// bulletGlyph is the rendered bullet of a real markdown list entry.
const bulletGlyph = "•"
// listIndent is the hanging indent under a bullet line.
const listIndent = " "
// isListEntry reports whether a line IS a markdown list entry: a
// leading -, *, +, or • bullet, or an ordered "N." / "N)" marker,
// followed by whitespace and text. A sentence never matches.
func isListEntry(line string) bool {
t := strings.TrimLeft(line, " \t")
switch {
case strings.HasPrefix(t, "- "), strings.HasPrefix(t, "* "),
strings.HasPrefix(t, "+ "), strings.HasPrefix(t, "• "):
return true
}
// ordered: digits, then '.' or ')', then a space
i := 0
for i < len(t) && t[i] >= '0' && t[i] <= '9' {
i++
}
if i == 0 || i > 4 || i >= len(t) {
return false
}
if t[i] != '.' && t[i] != ')' {
return false
}
return len(t) > i+1 && (t[i+1] == ' ' || t[i+1] == '\t')
}
// stripListMarker drops a matched list marker (and its whitespace).
func stripListMarker(line string) string {
t := strings.TrimLeft(line, " \t")
for _, p := range []string{"- ", "* ", "+ ", "• "} {
if strings.HasPrefix(t, p) {
return strings.TrimLeft(t[len(p):], " \t")
}
}
i := 0
for i < len(t) && t[i] >= '0' && t[i] <= '9' {
i++
}
if i > 0 && i < len(t) && (t[i] == '.' || t[i] == ')') {
return strings.TrimLeft(t[i+1:], " \t")
}
return t
}
// mdSegments partitions ONE prose segment (already code-free) into
// paragraph and list segments, in order. Blank lines separate blocks;
// consecutive list-entry lines group into one list seg.
func mdSegments(prose string) []mdSeg {
var out []mdSeg
var para []string
var list []string
flushPara := func() {
if len(para) > 0 {
out = append(out, mdSeg{text: stripInline(strings.Join(para, "\n"))})
para = nil
}
}
flushList := func() {
if len(list) > 0 {
out = append(out, mdSeg{list: true, items: list})
list = nil
}
}
for _, ln := range strings.Split(prose, "\n") {
if strings.TrimSpace(ln) == "" {
flushPara()
flushList()
continue // a blank line separates blocks
}
if isListEntry(ln) {
flushPara()
list = append(list, stripListMarker(ln))
continue
}
flushList()
para = append(para, ln)
}
flushPara()
flushList()
return out
}
// stripInline honestly strips inline markdown markers from one text
// run: **bold**, __bold__, *italic*, _italic_, `code`, ~~strike~~.
// The words always survive; the markers never render.
func stripInline(text string) string {
var b strings.Builder
runes := []rune(text)
i := 0
for i < len(runes) {
r := runes[i]
switch r {
case '`', '*', '_', '~':
content, next, ok := inlineSpan(runes, i)
if ok {
b.WriteString(content)
i = next
continue
}
}
b.WriteRune(r)
i++
}
return b.String()
}
// inlineSpan, given a marker rune at index i ('*', '_', '~' or '`'),
// returns the span's CONTENT (the markers dropped) and the index just
// past the closer when a well-formed span starts there: **bold**,
// __bold__, ~~strike~~, *italic*, _italic_, `code`. The words always
// survive; the markers never render.
func inlineSpan(r []rune, i int) (content string, next int, ok bool) {
m := r[i]
// double-marker forms first: **x**, __x__, ~~x~~
if m != '`' && i+1 < len(r) && r[i+1] == m {
if c := findRun(r, i+2, m, 2); c > 0 {
return string(r[i+2 : c]), c + 2, true
}
}
if e := indexRune(r, i+1, m); e > i+1 {
return string(r[i+1 : e]), e + 1, true
}
return "", 0, false
}
// findRun locates a two-rune closer (e.g. "**" or "~~") starting at or
// after from; returns the index of the closer's first rune.
func findRun(r []rune, from int, c rune, n int) int {
for i := from; i+n <= len(r); i++ {
match := true
for j := 0; j < n; j++ {
if r[i+j] != c {
match = false
break
}
}
if match {
return i
}
}
return 0
}
func indexRune(r []rune, from int, target rune) int {
for i := from; i < len(r); i++ {
if r[i] == target {
return i
}
}
return -1
}
func hasPrefix(r []rune, i int, p string) bool {
pr := []rune(p)
if i+len(pr) > len(r) {
return false
}
for j, c := range pr {
if r[i+j] != c {
return false
}
}
return true
}
// hasSuffixAt reports whether the marker p appears at index i, and
// returns its index (i) when it does — 0 when it doesn't.
func hasSuffixAt(r []rune, i int, p string) int {
if hasPrefix(r, i, p) {
return i
}
return 0
}
// bulletLines renders ONE list item at width w: the bullet glyph on
// the first line, the wrapped continuation hanging-indented — one
// bullet per item, never per sentence.
func bulletLines(item string, w int) []string {
if w <= 2 {
return []string{bulletGlyph + item}
}
body := wrap(item, w-2)
out := make([]string, 0, len(body))
for i, l := range body {
if i == 0 {
out = append(out, bulletGlyph+" "+l)
continue
}
out = append(out, listIndent+l)
}
return out
}
// paraLines renders ONE paragraph at width w: word-wrapped prose.
func paraLines(text string, w int) []string { return wrap(text, w) }
// proseLines is the plain-path projection of a prose segment: the
// paragraph/list segments rendered as display lines at w, separated
// by blank lines where the source had them.
func proseLines(prose string, w int) []string {
var out []string
for _, seg := range mdSegments(prose) {
if out != nil && len(out) > 0 && out[len(out)-1] != "" {
// a block boundary the source carried as a blank line
out = append(out, "")
}
if seg.list {
for _, item := range seg.items {
out = append(out, bulletLines(item, w)...)
}
continue
}
out = append(out, paraLines(seg.text, w)...)
}
return out
}
+169
View File
@@ -0,0 +1,169 @@
// markdown_test.go — the D47 markdown-to-pane projection law (defects
// 1+3 of the live-use round): paragraphs render as PARAGRAPHS, only
// REAL markdown bullet entries render as bullets, code fences stay
// code blocks (D36), and inline markdown markers are handled or
// honestly stripped. NEVER per-sentence bullets: prose is prose.
package chatpane
import (
"strings"
"testing"
"time"
)
func mdMessage(content string) Message {
return Message{ID: "m-" + time.Now().Format("150405.000000000"),
Role: RoleAssistant, Content: content,
Ts: "2026-10-21T12:00:00Z"}
}
// renderPlain projects one message's body at a width with no ANSI —
// the linear projection is the structure test surface. Box chrome
// (the bubble borders) is stripped so content predicates run on the
// body text.
func renderPlain(m Message, w int) string {
p := New()
var out []string
for _, ln := range p.bubbleBody(m, w, false) {
t := strings.TrimSpace(ln)
t = strings.TrimPrefix(t, "│ ")
t = strings.TrimPrefix(t, "│")
t = strings.TrimPrefix(t, "◆ ")
t = strings.TrimSuffix(t, "│")
out = append(out, strings.TrimSpace(t))
}
return strings.Join(out, "\n")
}
// TestParagraphsRenderAsParagraphs: prose paragraphs separated by a
// blank line render as wrapped prose with NO bullet glyphs on any
// line, and the paragraph break survives as a blank line — the
// per-sentence-bullet misrender is structurally impossible.
func TestParagraphsRenderAsParagraphs(t *testing.T) {
content := "Rune routes work through the tasks pane.\n\nThe chat pane stays conversational.\nIt never bullet-points prose."
got := renderPlain(mdMessage(content), 60)
for _, ln := range strings.Split(got, "\n") {
trimmed := strings.TrimSpace(ln)
if strings.HasPrefix(trimmed, "•") || strings.HasPrefix(trimmed, "-") ||
strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "+") {
t.Fatalf("prose line rendered with a bullet glyph: %q\nfull:\n%s", ln, got)
}
}
if !strings.Contains(got, "tasks pane.") {
t.Fatalf("prose text lost, got:\n%s", got)
}
lines := strings.Split(got, "\n")
para1Idx, para2Idx := -1, -1
for i, ln := range lines {
if strings.Contains(ln, "tasks pane.") && para1Idx == -1 {
para1Idx = i
}
if strings.Contains(ln, "conversational.") {
para2Idx = i
}
}
if para1Idx == -1 || para2Idx == -1 {
t.Fatalf("paragraphs missing from render:\n%s", got)
}
if para2Idx != para1Idx+2 {
t.Fatalf("the blank-line paragraph break must survive between paragraphs (got idx %d and %d):\n%s", para1Idx, para2Idx, got)
}
}
// TestOnlyRealMarkdownBulletsRenderAsBullets: lines that ARE markdown
// list entries ("- item", "* item", "1. item") render with a bullet
// glyph and a hanging indent — and only those lines.
func TestOnlyRealMarkdownBulletsRenderAsBullets(t *testing.T) {
content := "Here is the plan:\n\n- first real item\n- second real item\n\n1. numbered entry one\n2. numbered entry two\n\nBack to prose."
got := renderPlain(mdMessage(content), 60)
bulletCount := 0
for _, ln := range strings.Split(got, "\n") {
trimmed := strings.TrimSpace(ln)
if strings.HasPrefix(trimmed, "•") {
bulletCount++
}
}
if bulletCount != 4 {
t.Fatalf("want exactly 4 bullet lines (2 dash + 2 numbered), got %d:\n%s", bulletCount, got)
}
if !strings.Contains(got, "• first real item") {
t.Fatalf("the first dash entry must render as a bullet line:\n%s", got)
}
if !strings.Contains(got, "• numbered entry one") {
t.Fatalf("numbered entries render as bullets too:\n%s", got)
}
if !strings.Contains(got, "Back to prose.") {
t.Fatalf("trailing prose lost:\n%s", got)
}
if strings.Contains(got, "- first") || strings.Contains(got, "1. numbered") {
t.Fatalf("raw markdown markers must not survive the projection:\n%s", got)
}
}
// TestContinuationLinesAreNotBullets: a list entry's wrapped
// continuation keeps its hanging indent, never a second bullet — and a
// prose sentence beginning with a word is never treated as a list.
func TestContinuationLinesAreNotBullets(t *testing.T) {
content := "- one entry whose text is long enough that it must wrap across several rendered lines at narrow widths"
got := renderPlain(mdMessage(content), 40)
bullets := 0
for _, ln := range strings.Split(got, "\n") {
if strings.HasPrefix(strings.TrimSpace(ln), "•") {
bullets++
}
}
if bullets != 1 {
t.Fatalf("a wrapped list entry keeps ONE bullet, got %d:\n%s", bullets, got)
}
}
// TestCodeFencesStayCodeBlocks: fenced code keeps the D36 collapsed
// code-block projection (the ᚼ summary line), never bullets, never
// prose-wrapped source.
func TestCodeFencesStayCodeBlocks(t *testing.T) {
content := "Intro paragraph.\n\n```go\nfmt.Println(\"hi\")\n```\n\nOutro paragraph."
p := New()
m := mdMessage(content)
if !hasExpandables(m.Content) {
t.Fatal("a fenced block must stay a code block (hasExpandables)")
}
lines := p.bubbleBody(m, 60, false)
joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "code (1 line)") {
t.Fatalf("the D36 collapsed code summary must render, got:\n%s", joined)
}
for _, ln := range lines {
if strings.HasPrefix(strings.TrimSpace(ln), "•") {
t.Fatalf("code fences never render as bullets:\n%s", joined)
}
}
}
// TestInlineMarkdownHandledOrStripped: bold/italic/code markers are
// honestly stripped from the rendered text — the reader sees the
// words, never the asterisks or backticks.
func TestInlineMarkdownHandledOrStripped(t *testing.T) {
content := "Use **bold words** and *italic ones* plus `code spans` in prose."
got := renderPlain(mdMessage(content), 80)
for _, marker := range []string{"**", "*", "`"} {
if strings.Contains(got, marker) {
t.Fatalf("inline %q marker must be stripped honestly, got:\n%s", marker, got)
}
}
for _, want := range []string{"bold words", "italic ones", "code spans"} {
if !strings.Contains(got, want) {
t.Fatalf("inline-formatted text %q lost its words:\n%s", want, got)
}
}
}
// TestPerSentenceBulletsImpossible — the D47 defect 3 regression pin:
// a multi-sentence answer with NO markdown list syntax renders with
// ZERO bullet glyphs anywhere.
func TestPerSentenceBulletsImpossible(t *testing.T) {
content := "First sentence stands alone.\nSecond sentence follows it.\nThird sentence closes the thought.\n\nNew paragraph starts here. It also has two sentences. No bullets anywhere."
got := renderPlain(mdMessage(content), 200)
if strings.Contains(got, "•") {
t.Fatalf("per-sentence bullets must be impossible, got:\n%s", got)
}
}
+34
View File
@@ -157,3 +157,37 @@ func TestCompactModelD45RefinedSpec(t *testing.T) {
t.Errorf("compacted name %d runes eats the bar: %s", len(r), got)
}
}
// TestOSCDetectionDenylist — the D47 diagnosis pin (defect 6/LIVE):
// the gate's old default (plain text for unknown terminals) was the
// live misfire. The wired detection renders the link on the modern
// default and keeps the plain degrade ONLY on the known non-carriers.
func TestOSCDetectionDenylist(t *testing.T) {
wireOSCDetection()
defer func() { linkVersions = func() bool { return false } }()
cases := []struct {
term string
want bool
}{
{"xterm-256color", true}, // Konsole/VTE direct — the owner's shape
{"tmux-256color", true}, // the tmux pass-through
{"alacritty", true}, // unknown-to-the-old-list modern terminal
{"", true}, // no TERM at all: modern default
{"dumb", false}, // the escape would print as garbage
{"linux", false}, // the console
}
for _, c := range cases {
t.Setenv("TERM", c.term)
t.Setenv("KONSOLE_VERSION", "")
t.Setenv("TERM_PROGRAM", "")
if got := linkVersions(); got != c.want {
t.Errorf("linkVersions(TERM=%q) = %v, want %v", c.term, got, c.want)
}
}
// the explicit carriers stay fast-path true
t.Setenv("TERM", "xterm-256color")
t.Setenv("KONSOLE_VERSION", "230804")
if !linkVersions() {
t.Error("KONSOLE_VERSION must stay a carrier fast path")
}
}
+68 -17
View File
@@ -460,14 +460,28 @@ func sendTaskCommand(m model) tea.Cmd {
return exec
}
// dispatchProviderClass names the provider class the dispatch budget
// keys on (D47 defect 3): the record's provider spelling — the local
// spellings (ollama/local, the providerless legacy shape) serve local,
// everything else routes cloud (the engine's servedByLocalBinding law
// mirrored in lib.TurnBudgetFor).
func dispatchProviderClass(m model) string {
if m.prov == nil {
return ""
}
return m.prov.Provider
}
// chatDispatch runs one composer send through the lane spine off the
// Update thread (the K20 pattern): the ctx carries the full completion
// budget (F3) — the core's operator ladder may hold the answer for its
// whole 300s window and no leg cuts it early. No face mutation happens
// here; the goroutine only touches the seam.
// Update thread (the K20 pattern): the ctx carries the WHOLE-TURN
// budget for the provider class (D47 defect 3 — a dispatched
// completion runs the core's tool loop, up to ToolLoopIterations model
// calls; #2536: locals get the extended class budget, cloud the
// shorter one). No face mutation happens here; the goroutine only
// touches the seam.
func chatDispatch(m model, text string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), lib.CompletionBudget)
ctx, cancel := context.WithTimeout(context.Background(), lib.TurnBudgetFor(dispatchProviderClass(m)))
defer cancel()
rep, _ := m.disp.Dispatch(ctx, laneconc.Chat, text)
return chatCompletionMsg{rep: rep, text: text}
@@ -827,6 +841,12 @@ func (m *model) applyChatReport(msg chatCompletionMsg) {
// row carries the real msg_id and CLAIMS this bubble (one fact).
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleAssistant,
Content: msg.rep.Result.Content, Ts: time.Now().UTC().Format(time.RFC3339)})
// D47 defect 2 (D44 doctrine: code = work content): the answer's
// fenced blocks route to the TASKS pane's work stream; the chat
// pane keeps the conversation (its D36 collapsed code summary).
for _, b := range tasks.AnswerCodeBlocks(msg.rep.Result.Content) {
m.face.AppendAnswerCode(b)
}
// the reply is a recorded conversation turn: the consent hub walks
// its lapse clock on it (a card unanswered across STALE_GAP_TURNS
// turns demotes to the hold queue)
@@ -1708,7 +1728,7 @@ func (m *model) resolveApproval(approved bool) tea.Cmd {
m.apprvBusy = true
res := bootResolver
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), lib.CompletionBudget)
ctx, cancel := context.WithTimeout(context.Background(), lib.TurnBudgetFor(dispatchProviderClass(*m)))
defer cancel()
env, err := res.ResolveApproval(ctx, approved)
return approvalReceiptMsg{approved: approved, env: env, err: err}
@@ -1757,6 +1777,10 @@ func (m *model) applyApprovalReceipt(msg approvalReceiptMsg) {
reply = "approved — resumed turn ended without text"
}
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleAssistant, Content: reply})
// D47 defect 2: the resumed turn's code routes to the work stream too.
for _, b := range tasks.AnswerCodeBlocks(reply) {
m.face.AppendAnswerCode(b)
}
}
// closeApprovalCard retires the card from the overlay band.
@@ -2341,6 +2365,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
// D47 defect 7: the info bar's MODEL token is the picker's
// click road (beside ctrl+m): a click inside the model token's
// columns (after the version + waves tokens, both stripped of
// ANSI — the OSC 8 escapes carry no columns) opens the
// centered searchable picker.
if msg.Y == m.face.Snapshot().H {
x0 := len([]rune(semantic.StripANSI(versionToken(m))))
if m.face.WaveCount() > 0 {
x0 += len([]rune(semantic.StripANSI(wavesToken(m))))
}
tok := semantic.StripANSI(modelToken(m))
tw := len([]rune(tok))
if tw > 0 && msg.X >= x0 && msg.X < x0+tw {
m.openPicker()
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
@@ -2437,13 +2478,28 @@ func versionToken(m model) string {
}
// 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.
// version hyperlink (D43 addendum 6 item 6, detect-then-render).
// D47 diagnosis (defect 6/LIVE): the binary was never stale and the
// emission is byte-proven under tmux — the gate itself was the
// misfire: its default was PLAIN TEXT for unknown terminals, so any
// launch context the carrier list didn't name (a wrapper, an unusual
// TERM) silently dropped the link. OSC 8 is near-universal on modern
// terminals (Konsole, VTE, kitty, alacritty, foot, WezTerm, iTerm,
// tmux, screen all carry it byte-intact), so the gate now DENYLISTS
// the known non-carriers and renders the link everywhere else; a
// non-carrier degrades the same way the plain path always did.
func wireOSCDetection() {
linkVersions = func() bool {
// known non-carriers first: the escape would print as garbage
// there — those keep the plain-text degrade.
switch t := os.Getenv("TERM"); t {
case "dumb", "linux", "vt100", "vt220", "xterm-mono", "cons25", "emacs":
return false
}
if os.Getenv("INSIDE_EMACS") != "" || os.Getenv("EMACS") != "" {
return false
}
// explicit carriers (the original allowlist, kept as fast paths)
if v := os.Getenv("KONSOLE_VERSION"); v != "" {
return true
}
@@ -2451,12 +2507,7 @@ func wireOSCDetection() {
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")
return true // the denylist default: modern terminals carry OSC 8
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
// modeltoken_click_test.go — the D47 defect 6 law: clicking the model
// token in the info bar opens the centered searchable model picker
// (the picker's second road beside ctrl+m). Red-first.
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"rune.dev/rune/tui/semantic"
)
// modelTokenXRange computes the info bar's model token column range
// the same way the shell must: after the version token and the waves
// token, both stripped of ANSI (the OSC 8 escapes carry no columns).
func modelTokenXRange(m model) (x0, w int) {
x0 = len([]rune(semantic.StripANSI(versionToken(m))))
if m.face.WaveCount() > 0 {
x0 += len([]rune(semantic.StripANSI(wavesToken(m))))
}
w = len([]rune(semantic.StripANSI(modelToken(m))))
return x0, w
}
// TestModelTokenClickOpensPicker — the click road: a click inside the
// model token's columns on the info bar row opens the centered picker
// (pickerOpen, empty query — searchable state), and a click outside
// the token does not.
func TestModelTokenClickOpensPicker(t *testing.T) {
m := sized(t, initialModel(), 80, 24)
m.prov = &providerSelection{Provider: "local", Model: "qwen3.6-27b"}
x0, w := modelTokenXRange(m)
if w <= 0 {
t.Fatal("the model token must render for the click to have a target")
}
next, _ := m.Update(tea.MouseClickMsg{X: x0 + 1, Y: m.face.Snapshot().H})
m2, ok := next.(model)
if !ok {
t.Fatalf("update returned %T, want model", next)
}
if !m2.pickerOpen {
t.Fatal("clicking the model token must open the centered model picker")
}
if m2.pickerQuery != "" {
t.Fatalf("the picker opens in searchable fresh state, query %q", m2.pickerQuery)
}
// a click LEFT of the token (inside the version token) does not open
next, _ = m.Update(tea.MouseClickMsg{X: 2, Y: m.face.Snapshot().H})
m3, _ := next.(model)
if m3.pickerOpen {
t.Fatal("a click outside the model token must not open the picker")
}
}
// TestModelTokenRidesAfterVersionAndWaves — the token's position law:
// version first, waves second, model third (the info bar order D43
// addendum 6 + D44-A froze).
func TestModelTokenRidesAfterVersionAndWaves(t *testing.T) {
m := sized(t, initialModel(), 80, 24)
m.prov = &providerSelection{Provider: "local", Model: "qwen3.6-27b"}
m.face.SetWaveFlight(wavesFlightOf(2))
bar := semantic.StripANSI(versionToken(m) + wavesToken(m) + modelToken(m))
vi := strings.Index(bar, "rune v")
wi := strings.Index(bar, "waves 2")
mi := strings.Index(bar, "qwen3.6")
if vi < 0 || wi < 0 || mi < 0 {
t.Fatalf("bar missing tokens: %q", bar)
}
if !(vi < wi && wi < mi) {
t.Fatalf("bar order must be version < waves < model, got %q", bar)
}
}
+45 -8
View File
@@ -28,16 +28,53 @@ type Completion struct {
Messages []CompletionMessage
}
// CompletionBudget is the dispatch leg's timeout budget (chat-live P1
// F3, D46 item 4): ONE coherent budget on every leg of the chain
// (this client, the entry serve proxy, the composer's dispatch ctx) —
// 480s. The D46 diagnosis: the 27B thinking models' observed worst
// turn runs 250-310s SOLO, and the stacked queue/memory/tool latency
// on top of that busted the old 330s (a 300s model call + overhead
// exceeds it); 480s sits above the worst real turn with headroom.
// Streaming (the honest long-term road) stays queued separately.
// CompletionBudget is the dispatch leg's PER-CALL timeout budget
// (chat-live P1 F3, D46 item 4): ONE coherent per-call budget on every
// leg of the chain — 480s. The D46 diagnosis: the 27B thinking models'
// observed worst turn runs 250-310s SOLO, and the stacked
// queue/memory/tool latency on top of that busted the old 330s (a
// 300s model call + overhead exceeds it); 480s sits above the worst
// real turn with headroom. Streaming (the honest long-term road)
// stays queued separately.
const CompletionBudget = 480 * time.Second
// ToolLoopIterations mirrors the core's bounded tool loop
// (runecore.maxToolIterations): ONE dispatched completion runs up to
// this many model calls inside itself (model → tools → model …), so
// the dispatch deadline must cover the SUM, not one call (D47
// defect 3; the live evidence #2533/#2536 — multi-iteration 27B turns
// still hit 'context deadline exceeded' past the 480s per-call
// budget).
const ToolLoopIterations = 6
// LocalTurnBudget is the LOCAL class's WHOLE-TURN dispatch budget
// (D47 defect 3, owner ruling #2536): the sum of the tool loop's
// model calls — ToolLoopIterations × the per-call 480s worst — which
// sits above the owner's extended 1200s floor. The 27B thinking
// locals are the class that actually runs multi-iteration loops.
const LocalTurnBudget = CompletionBudget * ToolLoopIterations
// CloudTurnBudget is the CLOUD class's whole-turn budget (D47
// defect 3, #2536): cloud model calls run seconds-to-a-minute, so six
// of them plus latency fit comfortably inside 600s — strictly shorter
// than the local class, never a flat 480s single-call cut.
const CloudTurnBudget = 10 * time.Minute
// TurnBudgetFor selects the WHOLE-TURN dispatch budget for one
// provider class: the local spellings (the engine's local binding —
// ollama/local and the providerless legacy shape) get the extended
// local budget; every cloud route gets the cloud budget; an unknown
// spelling gets the CONSERVATIVE local ceiling (an honest unknown
// never under-budgets a 27B loop).
func TurnBudgetFor(provider string) time.Duration {
switch provider {
case "ollama", "local", "":
return LocalTurnBudget
default:
return CloudTurnBudget
}
}
// CompletionMessage is one chat/completions message entry.
type CompletionMessage struct {
Role string
+6 -5
View File
@@ -153,10 +153,11 @@ type Repo struct {
entryBase string
hc *http.Client
// compHC is the DEDICATED completion client (chat-live P1 F3): the
// 10s blanket on hc fits reads, not completions — the core's
// operator ladder can legitimately hold a chat answer for the
// full 300s budget, so the dispatch leg carries its own >=330s
// client instead of being cut early.
// 10s blanket on hc fits reads, not completions — a dispatched
// completion runs the core's WHOLE tool loop (D47 defect 3: up to
// ToolLoopIterations model calls), so the client's timeout carries
// the local whole-turn ceiling; the per-class deadline rides the
// dispatch ctx the callers bind (TurnBudgetFor).
compHC *http.Client
serveCmd *exec.Cmd
closed bool
@@ -196,7 +197,7 @@ func Open(root string) (*Repo, error) {
root: root,
run: binRunner(binDir),
hc: &http.Client{Timeout: 10 * time.Second},
compHC: &http.Client{Timeout: CompletionBudget},
compHC: &http.Client{Timeout: LocalTurnBudget},
}
if err := r.startServe(binDir); err != nil {
os.RemoveAll(binDir)
+1 -1
View File
@@ -154,7 +154,7 @@ func fakeRepo(t *testing.T, run func(dir string, args ...string) ([]byte, error)
run: run,
entryBase: serve.URL,
hc: serve.Client(),
compHC: &http.Client{Timeout: CompletionBudget},
compHC: &http.Client{Timeout: LocalTurnBudget},
}
}
+107
View File
@@ -0,0 +1,107 @@
package lib
// turnbudget_test.go — the D47 defect 3 law: the context deadline
// budget covers the WHOLE TURN — the SUM of the tool loop's model
// calls (the core's bounded loop runs up to ToolLoopIterations calls
// inside ONE dispatched completion), and the budget is PER-MODEL-CLASS
// (owner ruling #2536: locals — 27B thinking models — get the extended
// whole-turn budget; cloud stays shorter). Red-first.
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestTurnBudgetCoversTheWholeToolLoop — structural fixture (the D47
// whole-turn law): the local turn budget must cover the tool loop's
// full structural sum (ToolLoopIterations × the per-call 480s budget)
// and the owner's extended floor (>= 1200s, #2536); the dispatch
// client's timeout must carry that longest class as its ceiling.
func TestTurnBudgetCoversTheWholeToolLoop(t *testing.T) {
if ToolLoopIterations != 6 {
t.Fatalf("ToolLoopIterations = %d, want the core's maxToolIterations mirror 6", ToolLoopIterations)
}
if LocalTurnBudget < ToolLoopIterations*CompletionBudget {
t.Fatalf("LocalTurnBudget = %v, want >= %v (the whole-turn sum: %d model calls × the per-call budget)",
LocalTurnBudget, ToolLoopIterations*CompletionBudget, ToolLoopIterations)
}
if LocalTurnBudget < 1200*time.Second {
t.Fatalf("LocalTurnBudget = %v, want >= 1200s (the owner's extended local floor, #2536)", LocalTurnBudget)
}
r := fakeRepo(t, nil, httptest.NewServer(http.NotFoundHandler()))
defer r.Close()
if r.compHC == nil || r.compHC.Timeout < LocalTurnBudget {
t.Fatalf("completion client budget = %+v, want the local whole-turn ceiling %v on the client", r.compHC, LocalTurnBudget)
}
}
// TestTurnBudgetPerModelClass — the budget is per-class (#2536):
// local spellings get the extended whole-turn budget; cloud stays
// shorter; unknown spellings get the conservative local ceiling (an
// honest unknown never under-budgets a 27B loop).
func TestTurnBudgetPerModelClass(t *testing.T) {
local := TurnBudgetFor("local")
if local != LocalTurnBudget {
t.Fatalf("TurnBudgetFor(local) = %v, want %v", local, LocalTurnBudget)
}
if got := TurnBudgetFor("ollama"); got != LocalTurnBudget {
t.Fatalf("TurnBudgetFor(ollama) = %v, want the local class budget", got)
}
if got := TurnBudgetFor(""); got != LocalTurnBudget {
t.Fatalf("TurnBudgetFor(\"\") = %v, want the local class budget (the providerless legacy shape serves local)", got)
}
cloud := TurnBudgetFor("openai")
if cloud <= 0 || cloud >= local {
t.Fatalf("TurnBudgetFor(openai) = %v, want a positive budget strictly shorter than the local %v", cloud, local)
}
if cloud < 10*time.Minute {
t.Fatalf("cloud turn budget %v must still cover a multi-iteration cloud loop", cloud)
}
}
// TestD47SlowToolLoopTurnSurvivesTheDispatch — the D47 simulation
// (scaled, red-first at the OLD single-call budget): a core whose ONE
// dispatched completion internally runs a 6-iteration tool loop (6 ×
// 400ms model calls = 2.4s, far past any single-call budget) must
// survive the dispatch leg. Scaled: the client rides
// TurnBudgetFor("local") compressed 1:1000 (2880ms) — and the negative
// control (a single-call-sized 400ms client) FAILS, proving the
// harness can detect an under-budget leg.
func TestD47SlowToolLoopTurnSurvivesTheDispatch(t *testing.T) {
const perCall = 400 * time.Millisecond
const iterations = 6
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// one dispatched completion = the whole tool loop: 6 sequential
// model calls, each perCall slow (the 27B multi-iteration shape)
for i := 0; i < iterations; i++ {
time.Sleep(perCall)
}
fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"loop done"}}]}`)
}))
defer srv.Close()
// negative control: the single-call-sized budget cuts the turn
under := fakeRepo(t, nil, srv)
under.compHC = &http.Client{Timeout: perCall}
_, err := under.Complete(context.Background(), Completion{Model: "qwen3.6-27b"})
if err == nil {
t.Fatal("the negative control must fail: a single-call budget cannot carry a 6-call turn")
}
under.Close()
// the whole-turn budget (local class, scaled 1:1000) carries it
r := fakeRepo(t, nil, srv)
defer r.Close()
r.compHC = &http.Client{Timeout: LocalTurnBudget / 1000}
res, err := r.Complete(context.Background(), Completion{Model: "qwen3.6-27b"})
if err != nil {
t.Fatalf("a 6-iteration slow-model turn must survive the whole-turn budget: %v", err)
}
if res.Content != "loop done" {
t.Fatalf("the looped answer must arrive intact: %+v", res)
}
}
+75 -34
View File
@@ -114,42 +114,39 @@ type writerFunc func([]byte) (int, error)
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
// TestFocusScriptThroughRealParser ports receipts/bubbletea/focus.json:
// the exact 10-key script bytes "2jjmqn1\t\x1bqq" through the real v2
// parser on the rich face. Parsed keys (incl. alt+q as distinct from q),
// every action, the modal swallow of q, focus restore, and the final
// fingerprint are pinned to the receipt.
// TestFocusScriptThroughRealParser — the D46 keyboard law, re-pinned
// 2026-10-21 (the D47 re-pin): plain letters TYPE. The old receipt's
// "m opens / q quits" is superseded — q is a letter the composer owns
// (owner ruling D46 addendum: typing must never exit), and the modal
// openers are ctrl+m/ctrl+h (the palette/click roads in production).
// This pin drives the REAL v2 parser through: focus + nav keys act; q
// produces NO action and never quits; alt+q stays atomic and actionless.
func TestFocusScriptThroughRealParser(t *testing.T) {
if testing.Short() {
t.Skip("runs a real tea program")
}
const script = "2jjmqn1\t\x1bqq" // 2,j,j,m,q(swallowed),n,1,tab,alt+q(no action),q(quit)
const script = "2jjq\x1bq" // 2,j,j,q(types — never quits),alt+q(atomic, no action)
res := runFaceProgram(t, NewRich(80, 24), bytes.NewReader([]byte(script)), func(p *tea.Program) {
go func() {
time.Sleep(2 * time.Second)
p.Send(FinishMsg{}) // safety: script's final q quits on its own
p.Send(FinishMsg{}) // q did NOT quit: the probe ends on time
}()
})
wantKeys := []string{"2", "j", "j", "m", "q", "n", "1", "tab", "alt+q", "q"}
wantActions := []string{"focus.tasks", "nav.next", "nav.next", "modal.open.approve",
"modal.swallow", "modal.cancel.approve", "focus.chat", "focus.tasks", "", "app.quit"}
wantKeys := []string{"2", "j", "j", "q", "alt+q"}
wantActions := []string{"focus.tasks", "nav.next", "nav.next", "", ""}
if !equalStrings(res.keys, wantKeys) {
t.Errorf("parsed keys = %v, want receipt %v", res.keys, wantKeys)
t.Errorf("parsed keys = %v, want %v", res.keys, wantKeys)
}
if !equalStrings(res.actions, wantActions) {
t.Errorf("actions = %v, want receipt %v", res.actions, wantActions)
t.Errorf("actions = %v, want %v (q types — no action, no quit)", res.actions, wantActions)
}
// q_swallowed_in_modal: the 5th key was q inside the approve modal and
// produced modal.swallow, not app.quit — the app kept running.
if res.actions[4] != "modal.swallow" {
t.Errorf("q under modal produced %q, want modal.swallow (receipt q_swallowed_in_modal)", res.actions[4])
}
// focus_restored (receipt): after cancel, focus was back on tasks.
// q_did_not_quit: the program survived to FinishMsg with focus and
// selection intact.
if !strings.Contains(res.finalFP, "focus=tasks;sel=2;modal=") {
t.Errorf("final fingerprint %q missing restored focus=tasks sel=2 no-modal", res.finalFP)
t.Errorf("final fingerprint %q missing surviving focus=tasks sel=2 no-modal", res.finalFP)
}
const wantFinal = "w=80;h=24;status=RUNNING;focus=tasks;sel=2;modal=;seq=-1;task-1=false;task-2=false;task-3=false;chat=1"
if res.finalFP != wantFinal {
@@ -157,6 +154,34 @@ func TestFocusScriptThroughRealParser(t *testing.T) {
}
}
// TestModalSwallowsQAndNCancels — the D46 modal law through the real
// parser: with the approve modal open (the programmatic road, since
// ctrl+m shares the \r byte with enter on the wire), q is SWALLOWED
// (typing never reaches the box under a decision boundary, never
// quits) and n cancels, restoring focus.
func TestModalSwallowsQAndNCancels(t *testing.T) {
if testing.Short() {
t.Skip("runs a real tea program")
}
face := NewRich(80, 24)
OpenModal(face.State(), ModalApprove)
res := runFaceProgram(t, face, bytes.NewReader([]byte("qn")), func(p *tea.Program) {
go func() {
time.Sleep(1 * time.Second)
p.Send(FinishMsg{})
}()
})
if !equalStrings(res.keys, []string{"q", "n"}) {
t.Errorf("parsed keys = %v, want [q n]", res.keys)
}
if !equalStrings(res.actions, []string{"modal.swallow", "modal.cancel.approve"}) {
t.Errorf("actions = %v, want [modal.swallow modal.cancel.approve]", res.actions)
}
if strings.Contains(res.finalFP, "modal=approve") {
t.Errorf("final fingerprint %q must show the modal cancelled", res.finalFP)
}
}
// TestAltQAtomic parses as ONE alt-modified key — the 10 atomic keyprobe
// receipts (pty-atomic-1..5, ssh-atomic-1..5): ESC q in a single
// terminal read is alt+q, performs no action, never quits.
@@ -179,43 +204,59 @@ func TestAltQAtomic(t *testing.T) {
}
// TestAltQSplitEscThenQ ports the 10 split keyprobe receipts
// (pty-split-1..5, ssh-split-1..5): when ESC and q arrive as separate
// reads (a real Escape press, then q — the parser's 50ms escape timeout
// fires between them) the parser produces esc, then q — and plain q
// DOES quit. The 200ms gap is 4x the vendored parser's escape timeout,
// so delivery shape, not timing luck, decides the outcome.
// (pty-split-1..5, ssh-split-1..5), re-pinned to the D46 law: when ESC
// and q arrive as separate reads (a real Escape press, then q — the
// parser's 50ms escape timeout fires between them) the parser produces
// esc, then q — and plain q TYPES: no action, no quit. The 200ms gap
// is 4x the vendored parser's escape timeout, so delivery shape, not
// timing luck, decides the outcome; the program SURVIVES both keys
// (the old receipt expected q to quit — superseded by the D46 addendum).
func TestAltQSplitEscThenQ(t *testing.T) {
if testing.Short() {
t.Skip("runs a real tea program (timing-dependent delivery split)")
}
res := runFaceProgram(t, NewLinear(80, 24), pipeReader(t, [][]byte{[]byte("\x1b"), []byte("q")}, 200*time.Millisecond), nil)
res := runFaceProgram(t, NewLinear(80, 24), pipeReader(t, [][]byte{[]byte("\x1b"), []byte("q")}, 200*time.Millisecond), func(p *tea.Program) {
go func() {
time.Sleep(1 * time.Second)
p.Send(FinishMsg{}) // q typed, never quit: the probe ends on time
}()
})
if !equalStrings(res.keys, []string{"esc", "q"}) {
t.Errorf("split ESC then q parsed as %v, want receipt [esc q]", res.keys)
}
if !equalStrings(res.actions, []string{"", "app.quit"}) {
t.Errorf("split actions = %v, want receipt [\"\" app.quit] (esc unbound, q quits)", res.actions)
if !equalStrings(res.actions, []string{"", ""}) {
t.Errorf("split actions = %v, want [\"\" \"\"] (esc unbound, q types — D46)", res.actions)
}
}
// TestEnterConfirmsModalThroughRealParser: \r mid-stream is the enter
// key; under the approve modal it confirms (the m+enter keyboard path to
// approval the mouse-equivalence table names).
// TestEnterConfirmsModalThroughRealParser, re-pinned to the D46 law:
// \r mid-stream is the enter key; under the approve modal (opened by
// its programmatic road — plain m TYPES now, and ctrl+m shares the \r
// byte with enter on the wire) enter CONFIRMS the approval (the
// keyboard path the mouse-equivalence table names).
func TestEnterConfirmsModalThroughRealParser(t *testing.T) {
if testing.Short() {
t.Skip("runs a real tea program")
}
res := runFaceProgram(t, NewRich(80, 24), bytes.NewReader([]byte("2jm\r")), func(p *tea.Program) {
face := NewRich(80, 24)
face.State().Focus = FocusTasks
face.State().Sel = 1 // task-2
OpenModal(face.State(), ModalApprove)
res := runFaceProgram(t, face, bytes.NewReader([]byte("\r")), func(p *tea.Program) {
go func() {
time.Sleep(2 * time.Second)
p.Send(FinishMsg{})
}()
})
if !equalStrings(res.keys, []string{"2", "j", "m", "enter"}) {
t.Errorf("parsed keys = %v, want [2 j m enter]", res.keys)
if !equalStrings(res.keys, []string{"enter"}) {
t.Errorf("parsed keys = %v, want [enter]", res.keys)
}
if !equalStrings(res.actions, []string{"focus.tasks", "nav.next", "modal.open.approve", "modal.confirm.approve.task-2"}) {
if !equalStrings(res.actions, []string{"modal.confirm.approve.task-2"}) {
t.Errorf("actions = %v, want modal confirm via enter", res.actions)
}
if !strings.Contains(res.finalFP, "task-2=true") {
t.Errorf("final fingerprint %q must show task-2 approved", res.finalFP)
}
}
// pipeReader returns a reader whose Reads return the given chunks one at
+110
View File
@@ -0,0 +1,110 @@
// answercodes.go — the D47 defect 2 law (D44 doctrine: code is WORK
// content): fenced code blocks in ANSWERS route to the tasks pane's
// work stream as work artifacts — the chat pane keeps the conversation
// (its D36 collapsed code summary), the workshop pane holds the code.
// Bounded display state: the stream keeps the newest answerCodeBound
// artifacts.
package tasks
import (
"strings"
)
// AnswerCode is one fenced block routed from an answer.
type AnswerCode struct {
Lang string // the fence's info string ("" = plain)
Lines []string // the block's source lines, verbatim
}
// answerCodeBound caps the routed stream (drop-oldest).
const answerCodeBound = 12
// AnswerCodeBlocks extracts the fenced blocks of one answer message:
// ```-fenced runs (the opening fence's info string is the language
// hint). Unclosed fences run to the end of the message.
func AnswerCodeBlocks(content string) []AnswerCode {
var out []AnswerCode
var cur *AnswerCode
for _, ln := range strings.Split(content, "\n") {
t := strings.TrimLeft(ln, " ")
if strings.HasPrefix(t, "```") {
if cur == nil {
out = append(out, AnswerCode{Lang: strings.TrimSpace(t[3:])})
cur = &out[len(out)-1]
continue
}
cur = nil // the closing fence
continue
}
if cur != nil {
cur.Lines = append(cur.Lines, ln)
}
}
// drop empty blocks (a bare ``` pair with nothing inside)
var kept []AnswerCode
for _, b := range out {
if len(b.Lines) > 0 {
kept = append(kept, b)
}
}
return kept
}
// AppendAnswerCode routes one answer's code artifact into the work
// stream (the shell calls this when an answer lands). Reports how
// many artifacts the stream now holds.
func (f *Tasks) AppendAnswerCode(b AnswerCode) int {
f.answerCodes = append(f.answerCodes, b)
if len(f.answerCodes) > answerCodeBound {
f.answerCodes = f.answerCodes[len(f.answerCodes)-answerCodeBound:]
}
return len(f.answerCodes)
}
// answerCodes reports the routed stream (test seam).
// answerCodeExpanded reports the stream's expansion state.
func (f *Tasks) answerCodeExpanded() bool { return f.workCodeExpanded }
// ToggleWorkExpand flips the routed code artifacts between collapsed
// (one summary line each) and expanded (the code lines) and reports
// whether anything was there to flip.
func (f *Tasks) ToggleWorkExpand() bool {
if len(f.answerCodes) == 0 {
return false
}
f.workCodeExpanded = !f.workCodeExpanded
return true
}
// answerCodeLines renders the routed stream for a pane column of the
// given width: collapsed shows one summary line per artifact
// ("code · go (2 lines)"), expanded shows the code lines dimmed under
// the artifact's summary (the D36 expandable law in the workshop).
func (f *Tasks) answerCodeLines(pw int) []string {
if len(f.answerCodes) == 0 {
return nil
}
var out []string
out = append(out, clipWork("◆ answer code · "+itoa(len(f.answerCodes))+
" (e expands)", pw))
for _, b := range f.answerCodes {
lang := b.Lang
if lang == "" {
lang = "plain"
}
unit := "lines"
if len(b.Lines) == 1 {
unit = "line"
}
head := " ᚼ code · " + lang + " (" + itoa(len(b.Lines)) + " " + unit + ")"
out = append(out, clipWork(head, pw))
if !f.workCodeExpanded {
continue
}
for _, l := range b.Lines {
out = append(out, clipWork(" "+l, pw))
}
}
return out
}
+70
View File
@@ -0,0 +1,70 @@
// answercodes_test.go — the D47 defect 2 law (D44 doctrine: code is
// WORK content): fenced code blocks in answers route to the TASKS
// pane's work stream; the chat pane stays conversational. Red-first.
package tasks
import (
"strings"
"testing"
)
// TestAnswerCodeBlocksExtraction — the fence parser: fenced blocks
// extract with their language hint; prose never extracts.
func TestAnswerCodeBlocksExtraction(t *testing.T) {
answer := "Here is the fix.\n\n```go\nfmt.Println(\"hi\")\nx := 42\n```\n\nAnd another.\n\n```python\nprint('x')\n```"
blocks := AnswerCodeBlocks(answer)
if len(blocks) != 2 {
t.Fatalf("want 2 fenced blocks, got %d: %+v", len(blocks), blocks)
}
if blocks[0].Lang != "go" || strings.Join(blocks[0].Lines, "\n") != "fmt.Println(\"hi\")\nx := 42" {
t.Fatalf("first block wrong: %+v", blocks[0])
}
if blocks[1].Lang != "python" || blocks[1].Lines[0] != "print('x')" {
t.Fatalf("second block wrong: %+v", blocks[1])
}
if got := AnswerCodeBlocks("no code here, just prose"); len(got) != 0 {
t.Fatalf("prose must not extract code blocks, got %+v", got)
}
}
// TestAppendAnswerCodeRoutesToWorkStream — the tasks pane renders the
// routed code as a work-stream artifact: a collapsed summary line that
// EXPANDS to the code lines (the D36 expandable law in the workshop).
func TestAppendAnswerCodeRoutesToWorkStream(t *testing.T) {
f := New(80, 24)
n := f.AppendAnswerCode(AnswerCode{
Lang: "go",
Lines: []string{"fmt.Println(\"hi\")", "x := 42"},
})
if n != 1 {
t.Fatalf("first routed block appends, got %d", n)
}
rows, _ := f.treeRows(78, 40)
joined := strings.Join(rows, "\n")
if !strings.Contains(joined, "code · go (2 lines)") {
t.Fatalf("the collapsed summary must name the language and size, got:\n%s", joined)
}
if strings.Contains(joined, "fmt.Println") {
t.Fatalf("collapsed must not show the code body, got:\n%s", joined)
}
// expand shows the lines
if !f.ToggleWorkExpand() {
t.Fatal("expand must flip the code artifact")
}
rows, _ = f.treeRows(78, 40)
joined = strings.Join(rows, "\n")
if !strings.Contains(joined, "fmt.Println(\"hi\")") {
t.Fatalf("expanded must show the code body, got:\n%s", joined)
}
}
// TestAnswerCodeBounded — the routed stream is bounded display state.
func TestAnswerCodeBounded(t *testing.T) {
f := New(80, 24)
for i := 0; i < answerCodeBound+4; i++ {
f.AppendAnswerCode(AnswerCode{Lang: "go", Lines: []string{"x"}})
}
if n := len(f.answerCodes); n != answerCodeBound {
t.Fatalf("the routed stream keeps the newest %d, got %d", answerCodeBound, n)
}
}
+8
View File
@@ -91,6 +91,12 @@ type Tasks struct {
steerPending *SteerCommand
steerSent *SteerCommand
ledgerOpen map[string]bool
// D47 defect 2 (D44 doctrine: code = work content): the fenced
// blocks routed from ANSWERS into the work stream, with their
// expansion state. Bounded display state (drop-oldest).
answerCodes []AnswerCode
workCodeExpanded bool
}
// New returns the rich-projection task-tree face on the canonical
@@ -313,7 +319,9 @@ func (f *Tasks) Apply(k semantic.Key) (semantic.ActionID, bool) {
return TasksSwallow, false
case "e":
// expand/collapse the work blocks' thinking (queue item 2b)
// AND the routed answer-code artifacts (D47 defect 2)
f.ToggleWorkThinking()
f.ToggleWorkExpand()
return TasksSwallow, false
case "x", "X", "esc":
f.open = false
+10
View File
@@ -169,6 +169,7 @@ func (f *Tasks) treeRows(pw, railY int) ([]string, map[int]int) {
ledger, ledgerAt := f.ledgerSection(pw, railY)
scaff := f.scaffoldLines(pw)
work := f.workLines(pw)
codes := f.answerCodeLines(pw)
queue := f.queueLines(pw)
fixed := 4 // header + blank + blank + footer
if len(scaff) > 0 {
@@ -177,6 +178,9 @@ func (f *Tasks) treeRows(pw, railY int) ([]string, map[int]int) {
if len(work) > 0 {
fixed += len(work) + 1 // the work stream + its separating blank
}
if len(codes) > 0 {
fixed += len(codes) + 1 // the routed answer-code stream + its blank
}
if len(queue) > 0 {
fixed += len(queue) + 1 // the entry section + its separating blank
}
@@ -215,6 +219,12 @@ func (f *Tasks) treeRows(pw, railY int) ([]string, map[int]int) {
if len(work) > 0 {
rows = append(rows, strings.Repeat(" ", pw))
}
// the routed answer-code stream rides the work stream (D47 defect
// 2: the workshop holds the code the answers produced)
rows = append(rows, codes...)
if len(codes) > 0 {
rows = append(rows, strings.Repeat(" ", pw))
}
for i := f.view; i < len(ids); i++ {
// the tree rows yield to the fixed sections' budget — a long
// tree scrolls (view follows the cursor) instead of pushing the