Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
807199c089 | ||
|
|
24cafe1391 | ||
|
|
586dc3b9c4 | ||
|
|
930b4dbf55 | ||
|
|
541264812a | ||
|
|
639530b2cf | ||
|
|
75f872165b | ||
|
|
0fd8549f8b | ||
|
|
4e92bd573a | ||
|
|
4430b1ee14 | ||
|
|
241e8c1e97 | ||
|
|
9f39c40bfa | ||
|
|
f3092dbdaa | ||
|
|
6b42f18c2b | ||
|
|
66302f029e | ||
|
|
98d5dcd056 | ||
|
|
1525717e5e | ||
|
|
a0ea8eb051 | ||
|
|
0c1747dd21 | ||
|
|
9b17415d10 | ||
|
|
b5a1409472 | ||
|
|
b35078bbd0 |
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+157
@@ -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": []
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user