From 15c8d369dacd066cebc4697a5aef29bc8dc2c75b Mon Sep 17 00:00:00 2001 From: conductor Date: Thu, 24 Sep 2026 08:52:41 +0100 Subject: [PATCH] =?UTF-8?q?feat(scaffolding):=20T23=20=E2=80=94=20make=20S?= =?UTF-8?q?caffolding=20the=20agent's=20default,=20enforceable=20construct?= =?UTF-8?q?ion=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T22 recorded the exact remaining blocker: "no byte-level enforcement point exists ... Establishing MiMo-surface enforcement is NEW architecture (a tool.execute.before governance hook) and was NOT built here." T23 builds it from verified topology, not invention. THE SEAM (verified against @mimo-ai/plugin index.d.ts + the decompiled harness, not assumed): `tool.execute.before` blocks by setting output.cancel + cancelReason; the tool's execute is then never called. All hook files run sequentially, and subagent tool calls pass through the same event — so delegated construction inherits enforcement. ARCHITECTURE — two events, two jobs, ONE authority: messages.transform (turn boundary) -> classify once, mint the contract tool.execute.before (per call) -> deterministic scope enforcement Every decision derives from canonical state (construction_intent -> classify_mode -> scaffold -> check_scope). No truth is duplicated; the contextual entry was EXTRACTED from rune_operator._construction_activation into construction_activation.activate_from_text (operator path now calls it, net -96 lines) rather than copied. SCOPE — declared vs provisional. A scope the need states is declared and ENFORCED (out-of-scope mutation blocked). A need stating nothing gets a provisional scope: first contact records the working set, divergence extends and records rather than blocking. Rationale is measured, not theoretical — blocking on an unstated scope punishes legitimate multi-area work, and one mis-classified turn would otherwise block the operator's real work. LIVE DEFECT FOUND AND FIXED: within minutes of install, the turn boundary minted four bogus contracts from envelopes (subagent completion reports arriving as user-role messages) and then correctly enforced them against the operator's own work. The installed hook was removed, the contracts abandoned with defect provenance, the source fixed (harness-internal envelope guard, same class as builder-bridge.ts), and the hook reinstalled. Regression test included. RUNE-ESSENTIAL CLASSIFICATION + DURABLE AWARENESS: rune_relationship() classifies from evidence (never from a repo name); declare_relationship() records CORE (Spine `manages`) vs EXTERNAL_DEPENDENCY (external_dependency entity + `depends_on`) — an external project Rune relies on is never falsely absorbed into Rune core. Reuses Spine/ProjectRegistry/CapabilityLedger; no new registry. VERDICT: SCAFFOLDING_DEFAULT_BUILD_PATH_PARTIAL. Enforcement, contextual triggering, discussion safety, /build parity, unrelated-project exclusion and proportionality all pass. Four gates are unproven rather than failed: subagent inheritance demonstrated live, continuation/resume, automatic scope promotion, new-project end-to-end. Claiming PROVEN would require evidence not produced. Verification: 37 new tests (incl. two driving the real guard via node); 521 targeted regression; full suite 2254 passed / 1 failed — the single failure is a PRE-EXISTING lint-ledger drift, proven by stashing all changes and re-running at HEAD (BLE001 347 vs 353). Not re-baselined upward: the ledger may only shrink. ruff clean. Guard live on the real path (decision trail at /tmp/rune-scaffolding-guard.log). LOCAL_MODEL_COMPUTE_EXECUTED = NO. --- config/mimo-hooks/scaffolding-guard.ts | 242 +++++++++ config/umbrellas.json | 2 + docs/SCAFFOLDING_MIMO_DEFAULT.md | 213 ++++++++ docs/T23_SCAFFOLDING_DEFAULT_REPORT.md | 239 +++++++++ scripts/install_mimo_hook.py | 2 +- src/rune/construction_activation.py | 206 +++++++- src/rune/construction_authority.py | 647 +++++++++++++++++++++++++ src/rune/construction_authority_cli.py | 165 +++++++ src/rune/rune_operator.py | 114 +---- store/spine/current.json | 442 ++++++++++------- tests/test_construction_authority.py | 360 ++++++++++++++ 11 files changed, 2365 insertions(+), 267 deletions(-) create mode 100644 config/mimo-hooks/scaffolding-guard.ts create mode 100644 docs/SCAFFOLDING_MIMO_DEFAULT.md create mode 100644 docs/T23_SCAFFOLDING_DEFAULT_REPORT.md create mode 100644 src/rune/construction_authority.py create mode 100755 src/rune/construction_authority_cli.py create mode 100644 tests/test_construction_authority.py diff --git a/config/mimo-hooks/scaffolding-guard.ts b/config/mimo-hooks/scaffolding-guard.ts new file mode 100644 index 00000000..340165df --- /dev/null +++ b/config/mimo-hooks/scaffolding-guard.ts @@ -0,0 +1,242 @@ +import { execFile } from "node:child_process" +import { existsSync, appendFileSync } from "node:fs" + +// scaffolding-guard — the MiMo-surface construction enforcement edge (T23). +// +// WHY THIS EXISTS +// T22 proved the accepted Scaffolding V1 authority was reachable from the +// TUI/operator path but NOT from the agent's own mutation surface, and +// recorded the honest boundary: "no byte-level enforcement point exists … +// Establishing MiMo-surface enforcement is NEW architecture (a +// tool.execute.before governance hook) and was NOT built here — it is +// recorded as the exact remaining blocker." This file IS that seam. +// +// It is the harness half ONLY. Every decision is delegated to +// `src/rune/construction_authority_cli.py` → `rune.construction_authority`, +// which derives from the canonical construction store. There is no +// construction logic here: if you find yourself adding policy to this file, +// it belongs in Python instead (T23 §36 — no parallel construction system). +// +// TWO EVENTS, TWO JOBS +// experimental.chat.messages.transform — the TURN BOUNDARY. Classify the +// user's intent once and create/resume the canonical contract +// (tranche §83: classify at the task boundary, never per tool call). +// tool.execute.before — the ENFORCEMENT BOUNDARY. Per mutating tool call, +// ask whether the mutation is inside the active contract's approved +// scope; block it if not. +// +// BLOCKING CONTRACT (verified against @mimo-ai/plugin index.d.ts and the +// decompiled harness): mutate `output` — `output.cancel = true` plus +// `output.cancelReason`. The tool's execute is then never called and the +// model receives `cancelReason` as the tool result. There is no +// thrown-error convention. `output.args` is mutable but unused here. +// +// DEGRADATION (tranche §61/§62): a bridge failure must not silently become +// either a denial or a bypass. We cache the last known authority; if the +// bridge fails while a governed contract was active, we FAIL CLOSED +// (conversational mutation on an active governed build stops with an +// explicit reason). If no contract was active, we allow — ordinary +// non-construction work must never become unusable. +// +// CANONICAL SOURCE: config/mimo-hooks/scaffolding-guard.ts +// Installed to ~/.mimocode/hooks/scaffolding-guard.ts by +// `scripts/install_mimo_hook.py` (hash-verified, drift-detected). + +const REPO = "/mnt/workdrive/issue161-harness" +const CLI = `${REPO}/src/rune/construction_authority_cli.py` +const LOG = "/tmp/rune-scaffolding-guard.log" + +// Same durable interpreter resolution as builder-bridge.ts (T22 repair: +// never hardcode an ephemeral interpreter path). +const PY_CANDIDATES = [ + process.env.MIMO_BRIDGE_PY, + `${REPO}/.test-venv/bin/python`, + "/home/nick/.venvs/memory/bin/python3", + "/usr/bin/python3", +].filter((p) => typeof p === "string" && p.length > 0) + +function resolvePy() { + for (const p of PY_CANDIDATES) { + try { + if (existsSync(p)) return p + } catch {} + } + return null +} + +// Tools that can change project state. Reads/search/list are deliberately +// absent — they cannot mutate and must never be gated (tranche §12: +// governance is universal for construction, ceremony is not). +const MUTATING = new Set([ + "write", "edit", "patch", "multiedit", "multi_edit", "apply_patch", + "notebook_edit", "write_file", "edit_file", "str_replace", + "bash", "shell", "run_command", "execute", "terminal", +]) + +function log(line) { + try { + appendFileSync(LOG, `${new Date().toISOString()} ${line}\n`) + } catch {} +} + +function runCli(payload) { + return new Promise((resolve) => { + const py = resolvePy() + if (!py) { + log("bridge: NO INTERPRETER RESOLVED") + return resolve(null) + } + let done = false + const finish = (v) => { + if (!done) { + done = true + resolve(v) + } + } + try { + const child = execFile( + py, + [CLI], + { timeout: 8000, maxBuffer: 1 << 20 }, + (err, stdout) => { + if (err) { + log(`bridge: error ${err.message}`) + return finish(null) + } + try { + finish(JSON.parse(String(stdout))) + } catch (e) { + log(`bridge: bad JSON ${String(stdout).slice(0, 200)}`) + finish(null) + } + }, + ) + child.stdin?.end(JSON.stringify(payload)) + } catch (e) { + log(`bridge: spawn failed ${e}`) + finish(null) + } + }) +} + +// Last known authority, so a transient bridge failure can fail CLOSED +// without breaking non-construction work. +let lastAuthority = null // { active: boolean, contract_id?: string, at: number } +let lastTurnText = "" + +// Harness-internal traffic is NOT user construction intent. Measured defect +// (T23, live): without this guard the turn boundary minted bogus +// construction contracts from `` envelopes — subagent +// completion reports arriving as user-role messages — and then correctly +// blocked the operator's own legitimate work against them. Same class of +// guard builder-bridge.ts uses for `` / checkpoint steps. +// A genuine operator turn does not begin with an envelope tag. +const INTERNAL_ENVELOPE = + /^\s*(?:<[A-Za-z][A-Za-z0-9-]*|EXTRACTION REQUIRED|\[Never-Ask\]|\[inbox)/ + +function isInternal(text) { + return INTERNAL_ENVELOPE.test(text) +} + +function extractUserText(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m?.info?.role !== "user") continue + const texts = [] + for (const p of m.parts ?? []) { + if (p?.type === "text" && typeof p.text === "string") texts.push(p.text) + } + if (texts.length) return texts.join("\n").slice(0, 4000) + } + return "" +} + +export const ScaffoldingGuard = { + // ---- TURN BOUNDARY: classify once, create/resume the contract -------- + "experimental.chat.messages.transform": async (_input, output) => { + try { + 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 + + const res = await runCli({ + op: "begin", + text, + cwd: REPO, + origin: "mimo", + }) + if (!res || res.ok !== true) { + log(`begin: no result (${JSON.stringify(res)})`) + return + } + log( + `begin: mode=${res.mode} activated=${res.activated} ` + + `contract=${res.contract_id} intent=${res.intent_layer} ` + + `scope=${JSON.stringify(res.scope ?? [])}`, + ) + if (res.activated && res.contract_id) { + lastAuthority = { active: true, contract_id: res.contract_id, at: Date.now() } + } else { + lastAuthority = { active: false, at: Date.now() } + } + } catch (e) { + log(`begin: threw ${e}`) + } + }, + + // ---- ENFORCEMENT BOUNDARY: per mutating tool call -------------------- + "tool.execute.before": async (input, output) => { + try { + const tool = String(input?.tool ?? "") + if (!MUTATING.has(tool.toLowerCase())) return + + const res = await runCli({ + op: "check", + tool, + args: output?.args ?? {}, + cwd: REPO, + }) + + if (!res || res.ok !== true) { + // Bridge failure. Fail CLOSED only when a governed build was known + // active; otherwise let ordinary work continue (§61/§62). + if (lastAuthority?.active) { + output.cancel = true + output.cancelReason = + "Scaffolding guard unavailable while a governed construction " + + "contract is active. Consequential mutation stopped (fail-closed). " + + "Re-run once the Rune construction authority is reachable." + log(`check: FAIL-CLOSED on bridge failure (contract=${lastAuthority.contract_id})`) + } else { + log("check: bridge failure, no active contract — allowed") + } + return + } + + lastAuthority = { + active: !!res.contract_id && res.mode !== "NO_CONTRACT", + contract_id: res.contract_id ?? undefined, + at: Date.now(), + } + + log( + `check: tool=${tool} allow=${res.allow} mode=${res.mode} ` + + `contract=${res.contract_id} scope=${JSON.stringify(res.scope ?? [])} ` + + `checked=${JSON.stringify(res.checked ?? [])}`, + ) + + if (res.allow === false) { + output.cancel = true + output.cancelReason = String(res.reason || "Blocked by construction scope") + } + } catch (e) { + log(`check: threw ${e}`) + } + }, +} + +export default ScaffoldingGuard diff --git a/config/umbrellas.json b/config/umbrellas.json index a5f9524e..b94c2bac 100644 --- a/config/umbrellas.json +++ b/config/umbrellas.json @@ -224,6 +224,8 @@ "capability_ledger", "cognitive_budgeter", "construction_activation", + "construction_authority", + "construction_authority_cli", "construction_commands", "construction_decision", "construction_depth", diff --git a/docs/SCAFFOLDING_MIMO_DEFAULT.md b/docs/SCAFFOLDING_MIMO_DEFAULT.md new file mode 100644 index 00000000..16b8ce0d --- /dev/null +++ b/docs/SCAFFOLDING_MIMO_DEFAULT.md @@ -0,0 +1,213 @@ +# SCAFFOLDING AS THE AGENT'S DEFAULT CONSTRUCTION PATH (T23) + +_Current-version companion to `SCAFFOLDING_V1_DEFINITION.md` (which remains the +frozen T18 record of Scaffolding V1 as it existed then). This document covers +what T23 added: the agent-mutation enforcement edge, contextual triggering on +the agent path, Rune-essential classification, and the residuals that remain +open. It does not restate V1 — read the definition first._ + +## The problem T23 solved + +T22 proved the accepted V1 authority was reachable from the TUI/operator path +but not from the agent's own mutation surface, and recorded the boundary +verbatim: + +> "no byte-level enforcement point exists. `tool_executor.py` never consults +> construction state, so the 19A invariant gates only the /build-start route. +> Establishing MiMo-surface enforcement is NEW architecture (a +> `tool.execute.before` governance hook) and was NOT built here — it is +> recorded as the exact remaining blocker." + +T23 built exactly that, from the real topology rather than from invention. + +## The seam (verified, not assumed) + +`tool.execute.before` is a real hook event with real blocking semantics: + +```ts +"tool.execute.before"?: ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: any; cancel?: boolean; cancelReason?: string }, +) => Promise +``` + +Verified against `~/.mimocode/node_modules/@mimo-ai/plugin/dist/index.d.ts` and +the decompiled harness: after the event fires, `if (output.cancel)` returns a +cancelled tool result carrying `cancelReason` and **the tool's `execute` is +never called**. There is no thrown-error convention; `output.args` is mutable. +Every registered hook handler runs, sequentially. + +Two consequences that matter: + +* **Subagent tool calls pass through the same event** (the harness fires it for + the subagent spawn itself, and subagent sessions run the same tool pipeline), + so delegated construction inherits enforcement without a separate mechanism; +* blocking is per-call and deterministic — no model is involved. + +## Architecture — two events, two jobs, one authority + +``` +USER TURN + │ + ├─ experimental.chat.messages.transform ← TURN BOUNDARY + │ scaffolding-guard.ts → construction_authority_cli.py + │ → construction_activation.activate_from_text + │ → intent layer (MEANING) → classify_mode (MODE) → activate + │ → mints ONE bounded Construction Contract + │ + └─ tool.execute.before ← ENFORCEMENT BOUNDARY + scaffolding-guard.ts → construction_authority_cli.py + → construction_authority.check_mutation + → Scaffold.check_scope (canonical mechanical check) + → allow | block +``` + +Classification happens **once per turn**, never per tool call (§83). Per-call +enforcement is deterministic and reads only the canonical store. + +### Authority is not duplicated + +Every decision derives from canonical state: + +| Concern | Canonical owner (reused, not copied) | +|---|---| +| Meaning / referents | `construction_intent.resolve_construction_intent` | +| Mode | `construction_mode.classify_mode` (the ONE classifier) | +| Contract + phase machine | `scaffold.Scaffold` (`new_contract`, `transition_contract`) | +| Active contract + scope projection | `construction_interface.construction_context` | +| Mechanical scope verdict | `scaffold.Scaffold.check_scope` | +| Project existence / index | `project_registry.ProjectRegistry` | +| Rune's structural self-model | `spine.py` (relationship edges) | +| Capability + dependency record | `capability_ledger.CapabilityLedger` | + +T23 adds **one** thing: `src/rune/construction_authority.py`, the decision +layer that reads those authorities and answers "may this mutation proceed?". +It holds no truth of its own. + +The contextual entry itself was **extracted**, not duplicated: the body of +`rune_operator._construction_activation` became +`construction_activation.activate_from_text`, and the operator path now calls +it. One implementation, two callers. + +## Contextual triggering + +Natural language enters the canonical path with no `/build`: + +``` +"build a provider manager" → EXISTING_PROJECT_CHANGE → contract minted +"implement the next Memory subsystem" → EXISTING_PROJECT_CHANGE → contract minted +"add account switching across backend and UI" → EXISTING_PROJECT_CHANGE → contract +"fix this typo" → LOCAL_CHANGE → no contract +"explain how this module works" → NOT_CONSTRUCTION → no state +"don't build anything yet, just inspect it" → NOT_CONSTRUCTION → no state +``` + +`/build` and the contextual route converge on the same classifier, the same +contract format and the same phase machine. `/build` removes ambiguity; it +holds no authority the contextual route lacks. + +### Target evidence + +`classify_mode` deliberately returns UNKNOWN without target/existence evidence +("never silently chooses"). On the agent path the working directory plus its +registered identity *is* evidence, so `construction_authority.target_evidence` +supplies it — as **evidence to the classifier**, not as a mode decision. With +no evidence the classifier still yields UNKNOWN and surfaces probes. + +## Scope: declared vs provisional + +Scope is the enforcement primitive, and `check_scope` is the single mechanical +authority ("unscoped → unknown, never silently green" is preserved). + +* **Declared scope** — the need itself names paths (e.g. "refactor + `src/rune/scaffold.py` and `src/rune/spine.py`"). Out-of-scope mutation is + **blocked** with a reason that states the lawful way forward (confine, or + record a governed scope change). This is the §60 negative proof. +* **Provisional scope** — the need names nothing. The first mutations establish + the working set as directory prefixes, recorded on the contract as + `identities.scope` with `scope_provisional = true`. Divergence **extends and + records**; it does not block. `seal_scope()` promotes provisional → declared. + +Why provisional exists (measured, not theorised): blocking on an +unstated scope punishes legitimate multi-area work — a build that touches +`src/` then `tests/` then `docs/` is not scope drift. It was also measured +live that a single mis-classified turn would then block the operator's real +work. Proportionality (§12) wins over maximum teeth. + +## Fail-closed, honestly bounded + +* Bridge failure while a governed contract is **known active** → the mutation is + **cancelled** (fail-closed). +* Bridge failure with no active contract → allowed. Ordinary read-only and + non-construction work must never become unusable (§61/§62). +* Read/search/list tools are never gated — they cannot mutate. +* Paths outside the repository are recorded, not blocked (scratch files are + normal); the generated-script route is covered on the **execution** side. + +## Rune-essential classification and durable awareness + +`rune_relationship(project)` classifies deterministically from evidence, never +from a repository name: + +1. Rune itself → `RUNE_CORE_COMPONENT`; +2. a recorded declaration → that; +3. a capability in Rune's own ledger whose `owning_project` is this project + → `RUNE_CORE_COMPONENT`; +4. otherwise `UNRELATED` — undeclared is the honest default, never a guess. + +`declare_relationship(project, CORE|EXTERNAL, evidence=[...])` records the +relationship durably in `store/rune_model/rune_relationships.json` and writes +Rune's relationship **to** the project into the canonical Spine: + +* `RUNE_CORE_COMPONENT` → `manages` edge (it is part of Rune's architecture); +* `RUNE_EXTERNAL_DEPENDENCY` → a `external_dependency` entity + `depends_on` + edge (Rune relies on it but does **not** own it — never falsely absorbed). + +Awareness is durable because it lives in the Spine, the relationships file and +the Project Registry — not in a prompt. `awareness_status(project)` is the +readback. + +## Known residuals (no hidden caveats) + +1. **Shell is reference-scanned, not predicted.** `check_mutation` extracts the + repository paths a shell command *mentions* and scope-checks those. A command + that computes its write set at runtime (obfuscated, or reading paths from a + file) can still write outside scope. Direct `sed -i`/redirect/`rm`/`cp`/`mv` + drift **is** caught (tested); arbitrary indirection is not. Stated plainly: + this is detection-and-governance for the normal path, not a sandbox. +2. **Unstated scopes do not block** (see provisional above). Enforcement teeth + require a declared or sealed scope. +3. **One measured classification false negative**: "introduce plugin support + throughout the application" resolves to DISCUSSION and so mints no contract. + The intent layer is V1 authority; T23 did not add a second classifier to + patch it. Recorded as an open defect. +4. **Ambiguous phrases over-trigger** ("fix login", "add logging" → a contract + with a provisional scope). Consequence is a recorded contract, not a blocked + build. +5. **Agent-side only.** `tool_executor.py` (Rune's own local runtime) is still + not gated; the agent harness is the surface that actually mutates today. +6. **Not yet proven:** subagent scope inheritance end-to-end on a real + delegated build, cross-project (non-Rune) governance, and continuation after + a process restart. The hook covers subagent tool calls by construction, but + that has not been demonstrated live in this tranche. + +## Operator surface + +Inspection is read-only and needs no commands during normal work: + +```bash +python3 src/rune/construction_authority_cli.py status +python3 src/rune/construction_authority_cli.py awareness --project rune +echo '{"op":"check","tool":"write","args":{"filePath":"src/rune/x.py"}}' \ + | python3 src/rune/construction_authority_cli.py +``` + +Evidence trail: `/tmp/rune-scaffolding-guard.log` (one line per decision). + +## Installation + +`config/mimo-hooks/scaffolding-guard.ts` is canonical; +`scripts/install_mimo_hook.py` installs it hash-verified into +`~/.mimocode/hooks/` alongside `builder-bridge.ts` and `event-wait-guard.ts`. +The guard contains no construction policy — policy lives in Python. If you are +about to add policy to the hook, put it in `construction_authority.py` instead. diff --git a/docs/T23_SCAFFOLDING_DEFAULT_REPORT.md b/docs/T23_SCAFFOLDING_DEFAULT_REPORT.md new file mode 100644 index 00000000..a205220e --- /dev/null +++ b/docs/T23_SCAFFOLDING_DEFAULT_REPORT.md @@ -0,0 +1,239 @@ +# T23 — SCAFFOLDING AS THE AGENT'S DEFAULT CONSTRUCTION PATH + +**Implementation + enforcement report.** Branch `m10-derive-refresh`, base `a5da6e0`. + +**`LOCAL_MODEL_COMPUTE_EXECUTED = NO`** · no production Memory mutation · no new architecture. + +--- + +## 1. Starting identity + +| | | +|---|---| +| branch | `m10-derive-refresh` | +| HEAD (base) | `a5da6e0` (#415 AH closure measurement pass) | +| gate scope | through `AG-` | +| deployed `gitea/master` | `c9095f5` @ `0.88.3` | +| T22 verdict | `PARTIALLY_WIRED — TUI_CONNECTED_BUT_MIMO_BYPASS` | + +## 2. Canonical references used (read first, per §1) + +`docs/SCAFFOLDING_V1_DEFINITION.md` · `docs/SCAFFOLDING.md` · `docs/SCAFFOLDING_IMPLEMENTATION_AUDIT.md` · the live modules (`construction_mode`, `construction_intent`, `construction_activation`, `construction_interface`, `scaffold`, `spine`, `structural_store`, `capability_ledger`, `project_registry`) · `store/scaffold/capability_ledger.json` · T22 commit `68b1a6b`. + +**No path in the mandate was stale.** All named modules exist under their historical names. + +## 3. The seam — found, not invented (§8/§17) + +The mandate forbade fabricating a hook. The seam was verified in three places before a line was written: + +* `~/.mimocode/node_modules/@mimo-ai/plugin/dist/index.d.ts` — the `Hooks` interface; +* the decompiled `mimo` binary — the trigger loop and the cancellation branch; +* `…/skills/evolve/reference/hook-api.md` — the documented contract. + +```ts +"tool.execute.before"?: ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: any; cancel?: boolean; cancelReason?: string }, +) => Promise +``` + +Decompiled behaviour: after the trigger, `if (output.cancel)` returns a cancelled tool result carrying `cancelReason`, and **the tool's `execute` is never called**. All hook files run sequentially. **Subagent tool calls pass through the same event** (the harness fires it for the subagent spawn, and subagent sessions run the same tool pipeline). + +T22's own commit named this as the blocker: *"Establishing MiMo-surface enforcement is NEW architecture (a `tool.execute.before` governance hook) and was NOT built here — it is recorded as the exact remaining blocker."* T23 built exactly that. + +## 4. Actual architecture + +``` +USER TURN + ├─ experimental.chat.messages.transform TURN BOUNDARY (once per turn, §83) + │ scaffolding-guard.ts → construction_authority_cli.py + │ → construction_activation.activate_from_text + │ intent layer (MEANING) → classify_mode (MODE) → activate + │ → mints ONE bounded Construction Contract + └─ tool.execute.before ENFORCEMENT BOUNDARY (per call) + scaffolding-guard.ts → construction_authority_cli.py + → construction_authority.check_mutation + → Scaffold.check_scope (canonical mechanical check) → allow | block +``` + +**Files added** + +| File | Role | +|---|---| +| `src/rune/construction_authority.py` | the decision layer (reads canonical authorities; holds no truth) | +| `src/rune/construction_authority_cli.py` | JSON bridge for the harness (same pattern as `builder_bridge_cli.py`) | +| `config/mimo-hooks/scaffolding-guard.ts` | the harness edge (no policy — policy is Python) | +| `tests/test_construction_authority.py` | 37 tests | +| `docs/SCAFFOLDING_MIMO_DEFAULT.md` | current-version doc (V1 definition left frozen) | + +**Files changed** + +| File | Change | +|---|---| +| `src/rune/construction_activation.py` | added `activate_from_text` (the ONE contextual entry) + `derive_scope` + `mint_bounded_contract`; added `target_project`/`project_exists` evidence params | +| `src/rune/rune_operator.py` | `_construction_activation` body **replaced by a call** to that one entry (net −96 lines: one implementation, two callers) | +| `scripts/install_mimo_hook.py` | `scaffolding-guard.ts` added to the managed set | +| `config/umbrellas.json` | the two new modules registered under `perception` | +| `store/spine/current.json` | projection refreshed (import graph legitimately gained modules) | + +## 5. Contextual trigger — measured, not asserted + +| Phrase | Mode | Contract | +|---|---|---| +| "build a new media manager" | `EXISTING_PROJECT_CHANGE` | minted | +| "create a provider service" | `EXISTING_PROJECT_CHANGE` | minted | +| "implement the next Memory subsystem" | `EXISTING_PROJECT_CHANGE` | minted | +| "make a desktop app" | `EXISTING_PROJECT_CHANGE` | minted | +| "add account switching across backend and UI" (no "build") | `EXISTING_PROJECT_CHANGE` | minted | +| "replace persistence with the new architecture" | `EXISTING_PROJECT_CHANGE` | minted | +| "fix this typo" | `LOCAL_CHANGE` | none | +| "change this constant from 5 to 6" | `NOT_CONSTRUCTION` | none | +| "explain how this module works" | `NOT_CONSTRUCTION` | none | +| "don't build anything yet, just inspect it" | `NOT_CONSTRUCTION` | none | +| "could we potentially build X later?" | `NOT_CONSTRUCTION` | none | + +`/build` and the contextual route share the classifier, the contract format and the phase machine. `/build` removes ambiguity; it holds no authority the contextual route lacks. + +**Target evidence.** `classify_mode` deliberately returns UNKNOWN without target/existence evidence. On the agent path the working directory *is* evidence, so `target_evidence()` supplies it — as evidence to the classifier, never as a mode decision. With no evidence the classifier still yields UNKNOWN and surfaces probes (§30 preserved). + +## 6. Scope — declared vs provisional + +`Scaffold.check_scope` remains the single mechanical authority ("unscoped → unknown, never silently green" preserved). + +* **Declared scope** (the need names paths) → out-of-scope mutation **blocked**. +* **Provisional scope** (the need names nothing) → first contact records the working set as directory prefixes; divergence **extends and records**, it does not block. `seal_scope()` promotes provisional → declared. + +Why provisional exists: blocking on an unstated scope punishes legitimate multi-area work (`src/` then `tests/` then `docs/` is not drift), and — **measured live** — one mis-classified turn would then block the operator's real work. §12 proportionality wins over maximum teeth. + +## 7. Mutation enforcement — evidence + +Live, on the real harness path (this session): + +``` +2026-09-24T07:47:14Z check: tool=bash allow=true mode=NO_CONTRACT +2026-09-24T07:47:15Z check: tool=edit allow=true mode=NO_CONTRACT +``` + +Through the hook, with a governed contract: + +| Attempt | Result | +|---|---| +| `edit src/rune/spine.py` (in declared scope) | allowed | +| `write src/other/unsafe.py` | **`cancel = true`** — "OUT OF SCOPE … BLOCKED" | +| `bash "sed -i 's/a/b/' src/other/unsafe.py"` | **`cancel = true`** | +| `write /tmp/scratch.py` (outside repo) | allowed, recorded `outside_repo` | +| `read /etc/passwd` | never gated | + +**§60 negative proof** (Rune layer, `test_declared_scope_blocks_out_of_scope`): contract scope `['src/rune/scaffold.py','src/rune/spine.py']`; `src/rune/other/unsafe.py` → `allow=False, mode=OUT_OF_SCOPE`. + +## 8. Shell boundary — exact guarantee and residual + +**Guaranteed:** a shell command that *mentions* a repo path outside a declared scope, and carries a mutation operator (`>`/`>>`/`tee`/`sed -i`/`cp`/`mv`/`rm`/`mkdir`/`chmod`/`dd`/`git add|commit|checkout|…`) is blocked. Verified: `sed -i` drift blocked through the hook. + +**Residual (stated plainly):** a command that computes its write set at runtime — obfuscated, or reading paths from a file — can still write outside scope. This is detection-and-governance for the normal path, **not a sandbox**. The generated-script route is covered on the execution side (the running command still references the repo paths it touches), not by intercepting the write. + +## 9. Rune-essential classification + durable awareness + +`rune_relationship(project)` — deterministic, evidence-based, never from a repository name: + +1. Rune itself → `RUNE_CORE_COMPONENT`; +2. a recorded declaration → that; +3. a capability in Rune's own ledger whose `owning_project` is this project → `RUNE_CORE_COMPONENT`; +4. otherwise `UNRELATED` (undeclared is the honest default — never a guess). + +Live readback against the real store: + +| project | relationship | registered | +|---|---|---| +| `rune` | `RUNE_CORE_COMPONENT` | yes | +| `stock-tool` | `UNRELATED` | yes (registry only — **not** absorbed into Rune core) | +| `random-unrelated-app` | `UNRELATED` | no | + +`declare_relationship(project, CORE|EXTERNAL, evidence=[…])` records durably in `store/rune_model/rune_relationships.json` **and** writes Rune's relationship *to* the project into the canonical Spine: `manages` for a core component, `external_dependency` entity + `depends_on` for an external dependency Rune relies on but does not own. Awareness is durable because it lives in the Spine/registry/relationships file — not in a prompt. + +Reused, not duplicated: Spine (`add_relationship`/`add_entity`), Project Registry (`register`/`get`), Capability Ledger (`define`). No new registry was created. + +## 10. The live defect found — and fixed + +**This is the tranche's most important finding, and it came from running the thing.** + +Within minutes of installing the guard, the turn boundary minted **four bogus construction contracts** from `` envelopes — subagent completion reports arriving as user-role messages. The guard then correctly enforced those contracts against the operator's *own* legitimate work, blocking edits to `config/mimo-hooks/scaffolding-guard.ts` and running the suite. + +Correct remediation was applied: the installed hook was **removed**, the mis-created contracts **abandoned** (`evidence_ref = t23-defect:harness-internal-message-misclassified`), the source fixed, and the hook reinstalled. + +Fix: the same class of guard `builder-bridge.ts` uses for ``, extended — a genuine operator turn does not begin with an envelope tag: + +```ts +const INTERNAL_ENVELOPE = + /^\s*(?:<[A-Za-z][A-Za-z0-9-]*|EXTRACTION REQUIRED|\[Never-Ask\]|\[inbox)/ +``` + +Regression test: `test_harness_internal_envelope_is_not_construction`. + +A second, related hardening followed from the same episode: **provisional scopes** (§6) — so that even a mis-classified turn records rather than blocks. + +## 11. Tests + +* `tests/test_construction_authority.py` — **37 passed**: contextual trigger matrix, local-change proportionality, override authority, no-invention, the §60 negative proof, provisional vs sealed scope, shell drift, read-tools ungated, terminal contracts inactive, Rune-essential classification, durable declaration, the CLI bridge (4 tests), and two end-to-end hook tests driving the real `scaffolding-guard.ts` through `node`. +* Targeted regression: **521 passed** across construction/scaffold/spine/registry/ledger/hook/bridge/operator/intent/activation/structural/behaviour/native. +* `ruff check` on all changed files: **clean**. +* Full suite: see §12. + +## 12. Full-suite state and pre-existing drift + +`2253 passed, 2 failed` on the first full run. Both failures were diagnosed rather than assumed: + +| Failure | Verdict | +|---|---| +| `test_umbrella_impact.py::test_manifest_maps_every_module` | **mine** — the two new modules were unmapped. Fixed by registering them under `perception` (alongside every other `construction_*` module). Now passes. | +| `test_ci_lint_contract.py::test_lint_baseline_matches_current_tree_counts` | **pre-existing** — proven by stashing *all* my changes and re-running: `BLE001: baseline 347 != live 353` **at HEAD**. The lint ledger is already stale in the repo. | + +Per §82 the pre-existing failure is distinguished from an introduced regression. It was **not** "fixed" by bumping the baseline upward — the ledger may only shrink, so raising it would be exactly the dishonesty the rule forbids. Fixing it properly means removing 6 pre-existing blind excepts, which is out of this tranche's scope. + +## 13. Verdict + +### `SCAFFOLDING_DEFAULT_BUILD_PATH_PARTIAL` + +| Gate | Status | +|---|---| +| G1 existing system reused | **PASS** — one classifier, one contract format, one phase machine; no parallel system | +| G2 contextual activation | **PASS** — natural language enters Scaffolding without `/build` (measured) | +| G3 discussion safety | **PASS** — discussion/negation/proposal create no construction state (5/5) | +| G4 new project | **PARTIAL** — `PROJECT_BUILD` mints + bootstraps on the contextual route; not demonstrated live end-to-end | +| G5 multi-file default | **PASS** — multi-file/implicit requests mint a governing contract | +| G6 scope promotion | **PARTIAL** — provisional accumulation works; automatic local→multi-file *promotion* is not implemented | +| G7 `/build` parity | **PASS** — same classifier, same contract, same transitions | +| G8 mutation enforcement | **PASS** — live: the guard evaluates every mutating call on the real path and blocks out-of-scope | +| G9 shell | **PASS (bounded)** — drift operators blocked; residual documented (§8) | +| G10 generated script | **PARTIAL** — covered on the execution side; direct interception not implemented | +| G11 subagent inheritance | **PARTIAL** — guaranteed by the hook contract (subagent calls pass through it) and documented, but **not demonstrated live** | +| G12 child scope | **PARTIAL** — inherits the parent's contract by construction; no narrower-scope mechanism | +| G13 continuation | **PARTIAL** — durable contract + `construction_context` readback exist; no live resume proof this tranche | +| G14 project identity | **PASS** — `target_evidence` from repo identity + Project Registry, not CWD alone | +| G15 completion authority | **NOT ADDRESSED** — completion still governed by the existing Scaffolding paths; this tranche did not change it | +| G16 Spine core awareness | **PASS** — `manages` edge + relationships file | +| G17 Spine external dependency | **PASS** — `external_dependency` entity + `depends_on` edge (tested) | +| G18 unrelated exclusion | **PASS** — live: `stock-tool` stays UNRELATED | +| G19 TUI truth | **NOT ADDRESSED** — TUI projection untouched | +| G20 proportionality | **PASS** — local fixes get no contract; provisional scopes never block | +| G21 no duplicate authority | **PASS** — the contextual entry was *extracted*, not copied (net −96 lines in the operator) | +| G22 verification independence | **PASS** — hook tests drive the real guard via `node`; scope verdicts come from the canonical `check_scope` | +| G23 full regression | **PASS with one pre-existing failure** (§12) | +| G24 live proof | **PASS** — the guard is live on the real path and evaluated this session's own tool calls | + +**Why PARTIAL and not PROVEN:** four gates are genuinely unproven rather than failed — subagent inheritance demonstrated live (G11), continuation/resume (G13), automatic scope promotion (G6), and new-project end-to-end (G4). Claiming `PROVEN` would require evidence I did not produce. + +## 14. Residuals — no hidden caveats + +1. **Shell is reference-scanned, not predicted** (§8). +2. **Unstated scopes do not block** — teeth require a declared or sealed scope. +3. **One measured classification false negative**: "introduce plugin support throughout the application" → DISCUSSION → no contract. The intent layer is V1 authority; T23 did not add a second classifier to patch it. +4. **Ambiguous phrases over-trigger** ("fix login", "add logging" → a contract with a provisional scope). Consequence is a recorded contract, not a blocked build. +5. **`tool_executor.py` (Rune's own local runtime) is still ungated** — the harness is the surface that actually mutates today. +6. **Pre-existing lint-ledger drift** at HEAD (§12). +7. **Subagent enforcement is by construction, not by live demonstration.** +8. **Bootstrapping exception (§76):** this tranche was not itself built through a Scaffolding contract — the enforcement it creates did not exist while it was being written. Recorded, not hidden. + +## 15. Not started (§48/§75/§97) + +No Memory, Agent Model, Substrate, Interaction Kernel, Mechanism Forge or Economic Operator work. E-G1/E-G2/E-G3 untouched. **HARD STOP honoured.** diff --git a/scripts/install_mimo_hook.py b/scripts/install_mimo_hook.py index a6ef3853..ef41552a 100755 --- a/scripts/install_mimo_hook.py +++ b/scripts/install_mimo_hook.py @@ -25,7 +25,7 @@ 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"] +MANAGED = ["event-wait-guard.ts", "builder-bridge.ts", "scaffolding-guard.ts"] def sha256(p: Path) -> str: diff --git a/src/rune/construction_activation.py b/src/rune/construction_activation.py index 5e9688de..9a3c554a 100644 --- a/src/rune/construction_activation.py +++ b/src/rune/construction_activation.py @@ -36,7 +36,211 @@ from rune import structural_store as ss from rune import scaffold as sc from rune import construction_mode as cm -__all__ = ["activate"] +__all__ = ["activate", "activate_from_text"] + + +def activate_from_text( + text: str, + *, + store: str | Path, + project: str = "issue161-harness", + builder: str = "rune-native", + origin: str = "native", + task_id: str | None = None, + conversation: list[dict] | None = None, + target_project: str | None = None, + project_exists: bool | None = None, +) -> dict: + """THE ONE contextual construction entry (T23 §36: no parallel system). + + Extracted from the operator path (`rune_operator._construction_activation`) + so the TUI/operator route and the MiMo-surface route share ONE + meaning→classify→activate implementation instead of two drifting copies. + + Order (T22 §17J criterion 1): the canonical intent layer resolves + MEANING, then the ONE classifier decides MODE, then activation + dispatches. A turn resolved as DISCUSSION records NO construction state. + An explicit `/scaffold-mode ` marker is USER AUTHORITY and + outranks the meaning gate. + + Deterministic; no model call. Returns a record describing what was + decided and what state was written. + """ + import datetime as _dt + import re as _re + + from rune.construction_mode import _STANDALONE_WORDS, classify_mode + + store_path = str(store) + record: dict = { + "mode": None, "activated": False, "lifecycle": "", + "intent_layer": None, "classification_evidence": [], + "contract_id": None, "origin": origin, "task_id": task_id, + } + + override = None + m = _re.match( + r"^(?:/scaffold-mode|scaffold-mode:)\s+" + r"(NOT_CONSTRUCTION|LOCAL_CHANGE|EXISTING_PROJECT_CHANGE|" + r"PROJECT_BUILD|UNKNOWN)\s+(.*)$", + text, _re.DOTALL) + if m: + override = m.group(1) + text = m.group(2).strip() + + low = text.lower() + tokens = set(_re.split(r"[^a-z0-9]+", low)) + # Target/existence EVIDENCE. Supplied by a caller that genuinely knows + # it (the agent harness resolves the real repository the operator is + # working in — stronger evidence than the token heuristic below, and it + # is EVIDENCE for the classifier, never a mode decision: the classifier + # still decides, and still returns UNKNOWN when the evidence is absent). + if target_project is not None: + target, exists = target_project, project_exists + elif "rune" in tokens: + target, exists = "rune", True + elif [w for w in _STANDALONE_WORDS if w in low]: + target, exists = None, False # evidenced: standalone = new project + elif "existing" in tokens: + target, exists = "rune", True + else: + target, exists = None, None # unknown → probes, not invention + + mode_decision = classify_mode( + text, target_project=target, project_exists=exists, + affected_surface=[], + explicit_override=override, + override_authority="user" if override else None) + + s = sc.Scaffold(store_path, project) + doc = s.load() + + try: + from rune.construction_intent import ( + ConversationContext, resolve_construction_intent) + ctx = ConversationContext() + for turn in (conversation or []): + _txt = str(turn.get("content", "")) + if _txt and _txt != text: + ctx.record_turn(str(turn.get("role", "user")), _txt) + intent = resolve_construction_intent(text, ctx) + except Exception as exc: # noqa: BLE001 — intent layer unavailable + # PRESERVE pre-existing behaviour exactly (classify+activate) rather + # than silently changing governance in an unproven direction. + intent = None + record["intent_error"] = str(exc)[:160] + + now = _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + if (override is None and intent is not None + and intent.action_intent == "DISCUSSION"): + record["mode"] = "NOT_CONSTRUCTION" + record["intent_layer"] = "DISCUSSION" + record["classification_evidence"] = intent.classification_evidence[:3] + doc.setdefault("observations", []).append({ + "kind": "construction_activated", "task": task_id, + "mode": "NOT_CONSTRUCTION", "activated": False, "lifecycle": "", + "classification_evidence": intent.classification_evidence[:3], + "intent_layer": "DISCUSSION", "origin": origin, + "observed_at": now}) + s.save(doc) + return record + + rec = activate( + mode_decision, + need=type("N", (), { + "objective": text, "project": "rune", + "need_id": f"{origin}-{task_id or now}", + "affected_surface": []})(), + scaffold=s, builder=builder) + doc.setdefault("observations", []).append({ + "kind": "construction_activated", "task": task_id, + "mode": mode_decision.mode, + "activated": rec.get("activated", False), + "lifecycle": rec.get("lifecycle", ""), + "classification_evidence": mode_decision.evidence[:3], + "origin": origin, "observed_at": now}) + s.save(doc) + + record["mode"] = mode_decision.mode + record["activated"] = rec.get("activated", False) + record["lifecycle"] = rec.get("lifecycle", "") + record["classification_evidence"] = mode_decision.evidence[:3] + record["contract_id"] = rec.get("contract_id") or rec.get("contract") + + # T23 §8/§48: a governed contextual build must resolve ONE authoritative + # Construction Contract — otherwise there is nothing for the mutation + # surface to be governed BY, and "Scaffolding is the default build path" + # would be unenforceable. Mint a bounded contract for the modes that + # represent real construction (EXISTING_PROJECT_CHANGE / PROJECT_BUILD). + # LOCAL_CHANGE stays ceremony-light (§12 proportionality) — bounded + # governance, no contract, exactly as `activate()` documents. + if (record["activated"] + and mode_decision.mode in ("EXISTING_PROJECT_CHANGE", + "PROJECT_BUILD")): + minted = mint_bounded_contract( + s, text=text, origin=origin, task_id=task_id, now=now) + record["contract_id"] = minted.get("contract_id") + record["scope"] = minted.get("scope") + record["mint"] = minted + + return record + + +def derive_scope(text: str) -> list[str]: + """Derive an initial mutation scope from explicit path references in the + need. Deliberately conservative: only paths the user actually named. + An empty result is honest — the enforcement layer then establishes the + scope from first contact (see construction_authority.establish_scope), + rather than inventing a scope the need never stated.""" + import re as _re + out: list[str] = [] + for token in _re.findall(r"[A-Za-z0-9_][A-Za-z0-9_./\-]*/[A-Za-z0-9_./\-]+", text): + token = token.strip(".,;:()[]\"'") + if token.startswith(("http", "//")): + continue + if "/" in token and not token.startswith("/"): + out.append(token) + return sorted(set(out)) + + +def mint_bounded_contract(scaffold: sc.Scaffold, *, text: str, origin: str, + task_id: str | None, now: str) -> dict: + """Mint + approve the ONE governing contract for a contextual build. + + Uses the canonical `Scaffold.new_contract` and the canonical lifecycle + transitions — the same calls `/build start` makes — so there is one + contract format and one phase machine. Failure is recorded, never + silently ignored; the caller decides policy (the mutation surface + fails CLOSED when a governed build has no contract — tranche §61). + """ + import re as _re + try: + doc = scaffold.load() + slug = _re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40] or "build" + cid = f"ctx-{now}-{slug}" + scope = derive_scope(text) + scaffold.new_contract( + doc, contract_id=cid, need=text, origin=origin, scope=scope, + requirement=f"contextual construction ({origin})") + entity = next((e for e in doc.get("entities", []) + if e.get("id") == f"contract:{cid}"), None) + if entity is not None: + # A scope the NEED ITSELF stated is declared evidence → enforce + # it. With no stated scope the contract starts provisional: + # first contact records the working set, and enforcement begins + # when a scope is declared or sealed — never on a guess. + entity.setdefault("identities", {})["scope_provisional"] = not scope + scaffold.save(doc) + for phase in ("approved", "building"): + scaffold.transition_contract( + doc, cid, phase, + evidence_ref=f"contextual-activation:{origin}") + scaffold.save(doc) + return {"contract_id": cid, "scope": scope, "minted": True} + except Exception as exc: # noqa: BLE001 — reported, never swallowed + return {"contract_id": None, "minted": False, + "error": f"{type(exc).__name__}: {exc!s:.160}"} def _scaffold_for(project: str, *, create: bool, parent_scaffold: sc.Scaffold | None = None) -> sc.Scaffold | None: diff --git a/src/rune/construction_authority.py b/src/rune/construction_authority.py new file mode 100644 index 00000000..00bbffa7 --- /dev/null +++ b/src/rune/construction_authority.py @@ -0,0 +1,647 @@ +"""construction_authority.py — the agent-mutation-side construction authority. + +T23. Governing invariant (tranche §7): **real construction performed by the +agent must not bypass the canonical Scaffolding process.** + +This module is NOT a second authority and holds NO duplicate truth. Every +decision it makes is DERIVED from the canonical construction store: + + * the active contract + its approved scope come from the canonical + projection `ConstructionInterface.construction_context()` + (src/rune/construction_interface.py — the §15 shared Construction + Context, terminal-phase filtering included); + * the scope verdict comes from the canonical mechanical check + `Scaffold.check_scope()` (src/rune/scaffold.py) — "unscoped → unknown, + never silently green" is preserved exactly. + +Two operations, both deterministic, neither calls a model: + + begin(text, ...) — turn boundary: resolve meaning → + classify mode → activate/resume the + canonical contract (delegates wholly to + `construction_activation.activate_from_text`). + check_mutation(tool, paths, ...) — per tool call: is this mutation inside + the active contract's approved scope? + +Why the agent-mutation side needs this at all: `tool_executor.py` is Rune's +OWN local runtime tool layer. The agent harness (MiMoCode) mutates through a +different surface (its own write/edit/bash tools). T22 recorded the honest +boundary: "no byte-level enforcement point exists … Establishing +MiMo-surface enforcement is NEW architecture (a tool.execute.before +governance hook) and was NOT built here — it is recorded as the exact +remaining blocker." This module is the Rune-side half of that seam; the +harness half is config/mimo-hooks/scaffolding-guard.ts. +""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + +__all__ = [ + "MUTATING_TOOLS", + "repo_root", + "store_path", + "authority", + "check_mutation", + "establish_scope", + "seal_scope", + "paths_for_tool", + "rune_relationship", + "declare_relationship", + "awareness_status", + "target_evidence", +] + +# Tools whose execution can change project state. `read`/`glob`/`grep`-class +# tools are deliberately absent: they cannot mutate and must never be gated +# (tranche §12 — governance is universal for construction, ceremony is not). +MUTATING_TOOLS = frozenset({ + "write", "edit", "patch", "multiedit", "multi_edit", "apply_patch", + "notebook_edit", "write_file", "edit_file", "str_replace", + "bash", "shell", "run_command", "execute", "terminal", +}) + +PROJECT_ID = "issue161-harness" + +# Shell operators/tokens that indicate a command intends to MUTATE state. +# Used only to decide whether the paths referenced by a shell command need +# scope checking — not to predict the write set (see residual note below). +_SHELL_MUTATION = re.compile( + r"(?:^|\s)(?:>>?|tee|sed\s+-i|cp|mv|rm|rmdir|mkdir|touch|truncate|dd|" + r"chmod|chown|ln|install|rsync|patch|git\s+(?:add|commit|checkout|" + r"restore|reset|rm|mv|apply|merge|rebase|stash))(?:\s|$)" +) + +# Interpreters that can execute a script which mutates the repo +# (tranche §21 — the generated-script bypass). +_SCRIPT_EXEC = re.compile( + r"(?:^|\s)(?:python3?|node|bun|deno|sh|bash|zsh|perl|ruby|php)(?:\s|$)" +) + +_PATHISH = re.compile(r"[A-Za-z0-9_./\-]*[A-Za-z0-9_\-]/[A-Za-z0-9_./\-]*") + + +def repo_root() -> Path: + """The Rune repository root — derived from this module's own location + (src/rune/construction_authority.py), never from the caller's CWD.""" + return Path(__file__).resolve().parents[2] + + +def store_path(cwd: str | Path | None = None) -> str: + """Canonical construction store. Env override wins (tests/fixtures); + otherwise the repo's own store — same resolution as + `rune_operator._construction_store`.""" + env = os.environ.get("RUNE_CONSTRUCTION_STORE") + if env: + return env + return str(repo_root() / "store" / "scaffold") + + +def seal_scope(contract_id: str, *, cwd: str | Path | None = None, + scope: list[str] | None = None) -> dict: + """Promote a contract's scope from PROVISIONAL to DECLARED (enforced). + + This is the governed act that gives the enforcement layer its teeth: from + here on, mutation outside the scope is blocked (§49/§60). Called when the + scope is known from the need itself, when impact analysis has run, or + explicitly by the operator. Optionally replaces the scope outright. + """ + import datetime as _dt + + store = store_path(cwd) + from rune.scaffold import Scaffold + + s = Scaffold(store, PROJECT_ID) + doc = s.load() + entity = next((e for e in doc.get("entities", []) + if e.get("id") == f"contract:{contract_id}"), None) + if entity is None: + return {"sealed": False, "reason": "contract not found"} + identities = entity.setdefault("identities", {}) + if scope is not None: + identities["scope"] = sorted(set(scope)) + identities["scope_provisional"] = False + doc.setdefault("observations", []).append({ + "kind": "construction_scope_sealed", + "contract": contract_id, + "scope": identities.get("scope", []), + "observed_at": _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")}) + s.save(doc) + return {"sealed": True, "scope": identities.get("scope", [])} + + +def establish_scope(contract_id: str, paths: list[str], *, + cwd: str | Path | None = None, + reason: str = "first-contact") -> dict: + """Record a contract's mutation scope from observed first contact. + + Why this exists: at the turn boundary the need is classified but the + affected surface is not yet known — the canonical impact analysis runs + DURING construction, not before it. Minting every contextual contract + with an empty scope would leave the enforcement layer toothless + ("compliance cannot be proven" forever). So the first mutations a + governed build makes ESTABLISH the scope, as directory prefixes, and + are recorded on the canonical contract; from then on, drift outside + that scope is blocked and requires a governed scope change (§49). + + This is a governed, logged change to the canonical contract — not a + silent one — and it uses the canonical `identities.scope` field, so + `Scaffold.check_scope` remains the single mechanical authority. + """ + import datetime as _dt + + root = repo_root() + store = store_path(cwd) + prefixes: set[str] = set() + for p in paths or []: + rel = _rel(p, root) + if rel is None: + continue + parent = str(Path(rel).parent) + prefixes.add("." if parent in ("", ".") else parent) + if not prefixes: + return {"established": False, "reason": "no in-repo paths"} + + from rune.scaffold import Scaffold + + s = Scaffold(store, PROJECT_ID) + doc = s.load() + entity = None + for e in doc.get("entities", []): + if e.get("id") == f"contract:{contract_id}": + entity = e + break + if entity is None: + return {"established": False, "reason": "contract not found"} + + identities = entity.setdefault("identities", {}) + current = list(identities.get("scope") or []) + merged = sorted(set(current) | prefixes) + identities["scope"] = merged + # A scope that first contact established is PROVISIONAL: it records the + # observed working set but does not yet block divergence. Blocking on a + # provisional scope would punish legitimate multi-area work (a build that + # spans src/ then tests/ then docs/ is not scope drift), and — measured + # live — a single mis-classified turn would then block the operator's + # real work. Enforcement teeth attach to DECLARED scopes: a scope the + # need stated explicitly, or one set through a governed scope change. + # `seal_scope()` promotes a provisional scope to enforced. + identities["scope_provisional"] = True + doc.setdefault("observations", []).append({ + "kind": "construction_scope_established", + "contract": contract_id, + "scope": merged, + "provisional": True, + "reason": reason, + "observed_at": _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")}) + s.save(doc) + return {"established": True, "scope": merged, "added": sorted(prefixes), + "provisional": True} + + +def authority(cwd: str | Path | None = None) -> dict: + """The canonical active-construction projection for this repo. + + Returns the §15 Construction Context state, plus `store`. Cheap by + design (one store read) — callers on a hot path should memoise on the + store's mtime (see construction_authority_cli.py). + """ + from rune.construction_interface import ConstructionInterface + + store = store_path(cwd) + iface = ConstructionInterface( + builder="mimo-guard", project_store=store, project_id=PROJECT_ID) + env = iface.construction_context() + state = dict(env.get("state") or {}) + state["store"] = store + state["project_id"] = PROJECT_ID + return state + + +# -------------------------------------------------------------------------- +# Path extraction per tool +# -------------------------------------------------------------------------- + +# Argument keys that carry a target file path, across harness tool dialects. +_PATH_KEYS = ("filePath", "file_path", "path", "file", "target", "target_file", + "filename", "notebook_path") + + +def paths_for_tool(tool: str, args: dict, root: Path | None = None) -> list[str]: + """Extract the candidate repo paths a tool call will touch. + + For file tools this is exact (the argument IS the path). For shell tools + it is a *reference scan*: the repo paths a command mentions. That + distinction is deliberate and is the documented shell residual — see + `check_mutation` and docs/SCAFFOLDING_MIMO_DEFAULT.md. + """ + root = root or repo_root() + name = (tool or "").lower() + args = args or {} + + if name not in ("bash", "shell", "run_command", "execute", "terminal"): + out: list[str] = [] + for key in _PATH_KEYS: + val = args.get(key) + if isinstance(val, str) and val.strip(): + out.append(val.strip()) + return out + + command = args.get("command") or args.get("cmd") or "" + if not isinstance(command, str): + return [] + return _shell_referenced_paths(command, root) + + +def _shell_referenced_paths(command: str, root: Path) -> list[str]: + """Repo paths a shell command references (best-effort, evidence-only).""" + found: list[str] = [] + for token in _PATHISH.findall(command): + token = token.strip("\"'`;|&()") + if not token or token.startswith("-"): + continue + cand = Path(token) + try: + resolved = (root / cand).resolve() if not cand.is_absolute() else cand.resolve() + except (OSError, RuntimeError): + continue + try: + rel = resolved.relative_to(root) + except ValueError: + continue + if rel.parts and rel.parts[0] in (".git", "node_modules", ".test-venv"): + continue + found.append(str(rel)) + return sorted(set(found)) + + +def _rel(path: str, root: Path) -> str | None: + """Repo-relative posix path, or None when the path is outside the repo.""" + try: + p = Path(path) + resolved = (root / p).resolve() if not p.is_absolute() else p.resolve() + return str(resolved.relative_to(root)) + except (OSError, RuntimeError, ValueError): + return None + + +# -------------------------------------------------------------------------- +# The enforcement decision +# -------------------------------------------------------------------------- + + +def check_mutation(tool: str, paths: list[str], cwd: str | Path | None = None, + *, establish: bool = True) -> dict: + """Decide whether a mutating tool call may proceed. + + Fail-closed for GOVERNED construction (tranche §61): when an active + contract carries an approved scope and the mutation falls outside it, the + call is BLOCKED with an explicit reason the model can act on. An unscoped + active contract cannot prove compliance — it allows but is recorded + (never silently green: the canonical `check_scope` verdict for that case + is "unknown", and that verdict is surfaced verbatim). + """ + root = repo_root() + auth = authority(cwd) + decision: dict = { + "allow": True, + "mode": "NO_CONTRACT", + "tool": tool, + "reason": "", + "contract_id": None, + "phase": None, + "scope": [], + "checked": [], + "out_of_scope": [], + "authority": auth, + } + + if not auth.get("active"): + decision["reason"] = ("no active governed construction contract — " + "mutation proceeds") + return decision + + decision["contract_id"] = auth.get("contract_id") + decision["phase"] = auth.get("phase") + scope = list(auth.get("scope") or []) + decision["scope"] = scope + + rel_paths: list[str] = [] + for p in paths or []: + r = _rel(p, root) + if r is None: + # Outside the repo (e.g. /tmp scratch). Not scope-checkable and + # not itself a repo mutation — recorded, not blocked. The + # generated-script route is covered on the EXECUTION side + # (a shell command that runs the script still references the + # repo paths it touches). + decision["checked"].append({"path": p, "verdict": "outside_repo"}) + continue + rel_paths.append(r) + + if not rel_paths: + decision["reason"] = "no repo-relative mutation path in this call" + return decision + + from rune.scaffold import Scaffold + + s = Scaffold(auth["store"], PROJECT_ID) + doc = s.load() + try: + res = s.check_scope(doc, auth["contract_id"], rel_paths) + except ValueError as exc: + # Contract vanished between projection and check — do not silently + # allow a governed build to continue ungoverned. + decision.update( + allow=False, mode="AUTHORITY_UNAVAILABLE", + reason=(f"construction authority unavailable for contract " + f"{auth['contract_id']}: {exc}. Consequential mutation " + f"stopped (fail-closed). Re-establish the contract or " + f"record a governed scope change.")) + return decision + + decision["checked"] = [ + {"path": p, "verdict": ("in_scope" if p in res["in_scope"] + else "out_of_scope")} + for p in rel_paths] + + if res["verdict"] == "unknown": + if establish: + # First contact establishes the working scope (directory + # prefixes), recorded on the canonical contract. From here on + # the contract HAS a scope and drift is enforced against it. + est = establish_scope(auth["contract_id"], rel_paths, cwd=cwd) + if est.get("established"): + decision["scope"] = est["scope"] + decision.update( + mode="SCOPE_ESTABLISHED", + reason=("active contract had no scope; established from " + f"first contact: {est['scope']}")) + return decision + decision.update( + mode="UNSCOPED", + reason=("active contract has no approved scope — compliance " + "cannot be proven; mutation allowed and recorded")) + return decision + + if res["out_of_scope"]: + entity = next((e for e in doc.get("entities", []) + if e.get("id") == f"contract:{auth['contract_id']}"), None) + provisional = bool( + (entity or {}).get("identities", {}).get("scope_provisional")) + if provisional: + # The scope so far is an observation of what this build has + # touched, not a boundary anyone declared. Extend it and record — + # do NOT block. (Live-measured: blocking here turned one + # mis-classified turn into a block on the operator's real work.) + est = establish_scope(auth["contract_id"], rel_paths, cwd=cwd, + reason="provisional-extension") + decision.update( + mode="SCOPE_EXTENDED", + scope=est.get("scope", scope), + reason=("provisional working set extended to " + f"{est.get('scope')} — recorded, not blocked " + "(no declared scope to enforce against)")) + return decision + decision.update( + allow=False, + mode="OUT_OF_SCOPE", + out_of_scope=res["out_of_scope"], + reason=( + "OUT OF SCOPE for the active construction contract " + f"{auth['contract_id']} (phase={auth.get('phase')}). " + f"Approved scope: {scope}. Attempted outside scope: " + f"{res['out_of_scope']}. Consequential mutation BLOCKED. " + "Either (a) confine the change to the approved scope, or " + "(b) record a governed scope change on the contract " + "(Scaffold amendment / supersession) and then retry. Do not " + "work around this by shell redirection, a generated script, " + "or a delegated actor — those paths are checked too.")) + return decision + + decision["mode"] = "IN_SCOPE" + decision["reason"] = "mutation is within the approved construction scope" + return decision + + +# -------------------------------------------------------------------------- +# Rune-essential classification + durable awareness (tranche §41–§47) +# -------------------------------------------------------------------------- + +CORE = "RUNE_CORE_COMPONENT" +EXTERNAL = "RUNE_EXTERNAL_DEPENDENCY" +UNRELATED = "UNRELATED" +NOT_APPLICABLE = "SPINE_REGISTRATION_NOT_APPLICABLE" + +_RELATIONSHIPS_PATH = ("store", "rune_model", "rune_relationships.json") + + +def _relationships_file() -> Path: + return repo_root().joinpath(*_RELATIONSHIPS_PATH) + + +def _load_relationships() -> dict: + p = _relationships_file() + if not p.is_file(): + return {"schema_version": 1, "projects": {}} + try: + doc = json.loads(p.read_text()) + except (OSError, ValueError): + return {"schema_version": 1, "projects": {}} + doc.setdefault("schema_version", 1) + doc.setdefault("projects", {}) + return doc + + +def rune_relationship(project: str) -> dict: + """Classify a project's relationship to Rune. + + Deterministic and EVIDENCE-BASED, never inferred from a name alone + (tranche §41). Three sources, in order: + + 1. Rune itself → CORE. + 2. A recorded declaration (this module's `declare_relationship`) → that. + 3. A capability in Rune's OWN capability ledger whose `owning_project` + is this project → CORE (Rune's capability set depends on it). + + Otherwise UNRELATED. "Unknown" is never silently upgraded to a + relationship: an undeclared project stays UNRELATED until declared, + which is the honest state — the tranche requires the DECLARATION to be + an explicit, durable act, not a guess. + """ + project = (project or "").strip() + if not project: + return {"project": project, "relationship": UNRELATED, + "evidence": ["no project identity"]} + if project in (PROJECT_ID, "rune"): + return {"project": project, "relationship": CORE, + "evidence": ["project is Rune itself"]} + + rel = _load_relationships().get("projects", {}).get(project) + if rel and rel.get("relationship") in (CORE, EXTERNAL, UNRELATED): + return {"project": project, "relationship": rel["relationship"], + "evidence": rel.get("evidence", ["recorded declaration"])} + + try: + from rune.capability_ledger import CapabilityLedger + ledger = CapabilityLedger(str(repo_root() / "store" / "rune_model")) + for entry in (ledger.all() if hasattr(ledger, "all") else []): + if entry.get("owning_project") == project: + return {"project": project, "relationship": CORE, + "evidence": [ + f"owns Rune capability {entry.get('capability_id')}"]} + except Exception as exc: # noqa: BLE001 — ledger unavailable is not a verdict + return {"project": project, "relationship": UNRELATED, + "evidence": ["no recorded declaration; Rune capability ledger " + f"unavailable ({type(exc).__name__})"]} + + return {"project": project, "relationship": UNRELATED, + "evidence": ["no recorded declaration; no owned Rune capability"]} + + +def declare_relationship(project: str, relationship: str, *, + evidence: list[str] | None = None, + note: str = "") -> dict: + """Record a project's relationship to Rune durably. + + Reuses the EXISTING awareness structures rather than inventing a + registry (tranche §44): + * the Project Registry records that the project exists; + * the Spine records Rune's relationship TO the project + (`manages` for a Rune core component; `depends_on` for an external + dependency Rune relies on); + * this file records the declaration + its evidence, so the + classification is inspectable and survives restarts. + """ + import datetime as _dt + + if relationship not in (CORE, EXTERNAL, UNRELATED): + raise ValueError(f"invalid relationship: {relationship}") + + doc = _load_relationships() + now = _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + doc["projects"][project] = { + "project": project, + "relationship": relationship, + "evidence": list(evidence or []), + "note": note, + "declared_at": now, + } + path = _relationships_file() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(doc, indent=1, sort_keys=True) + "\n") + + spine_result = "not_applicable" + if relationship in (CORE, EXTERNAL): + spine_result = _record_spine_relationship(project, relationship, now) + + return {"project": project, "relationship": relationship, + "recorded": str(path), "spine": spine_result, + "evidence": list(evidence or [])} + + +def _record_spine_relationship(project: str, relationship: str, now: str) -> str: + """Write Rune's relationship TO the project into the canonical Spine. + + A Rune CORE component is something Rune `manages` (it is part of Rune's + architecture). An EXTERNAL dependency is something Rune `depends_on` but + does NOT own — the distinction the tranche requires (§42) so an external + project is never falsely absorbed into Rune core. Spine entity kinds and + relationship types are open-world (structural_store.add_relationship), + so this uses the existing vocabulary rather than adding a new registry. + """ + try: + from rune import spine as spine_mod + spine_path = repo_root() / "store" / "spine" / "current.json" + sdoc = spine_mod.load(spine_path) + if sdoc is None: + return "spine_unavailable" + target = f"project:{project}" + if relationship == EXTERNAL: + spine_mod.add_entity( + sdoc, id=target, kind="external_dependency", + tags=["rune-essential"], identities={"project": project}, + evidence_refs=[f"rune-relationship:{project}"], + maturity="DECLARED", ext={"declared_at": now}) + spine_mod.add_relationship( + sdoc, type="depends_on", from_id="project:rune", to_id=target, + evidence_refs=[f"rune-relationship:{project}"]) + else: + spine_mod.add_relationship( + sdoc, type="manages", from_id="project:rune", to_id=target, + evidence_refs=[f"rune-relationship:{project}"]) + spine_mod.save(sdoc, spine_path) + return "recorded" + except Exception as exc: # noqa: BLE001 — awareness must not break a build + return f"error: {exc!s:.120}" + + +def target_evidence(cwd: str | Path | None = None) -> dict: + """Resolve which project the operator is actually working in. + + This supplies EVIDENCE to the ONE classifier — it does not decide a mode + (tranche §29/§30: `classify_mode` still decides, and still returns + UNKNOWN when the evidence is absent). The agent harness has stronger + identity evidence than a working-directory guess: the repository root + plus its registered identity. + + Resolution order, strongest first: + 1. the working directory is inside the Rune repository → Rune; + 2. the working directory is inside a project the Project Registry + knows → that project; + 3. otherwise no target evidence (the classifier will surface probes). + """ + root = repo_root() + cwd_path = Path(cwd).resolve() if cwd else Path.cwd() + try: + cwd_path.relative_to(root) + return {"target_project": "rune", "project_exists": True, + "evidence": f"cwd inside the Rune repository ({root})"} + except ValueError: + # Not inside the Rune repository — fall through to registry evidence. + pass + + try: + from rune.project_registry import ProjectRegistry + registry = ProjectRegistry(store_path()) + for entry in (registry.all_projects() or []): + loc = entry.get("scaffold_location") + if not loc: + continue + try: + Path(cwd_path).relative_to(Path(loc).resolve()) + return {"target_project": entry.get("project_id"), + "project_exists": True, + "evidence": f"cwd inside registered project {loc}"} + except ValueError: + continue + except Exception as exc: # noqa: BLE001 — registry unavailable + return {"target_project": None, "project_exists": None, + "evidence": ("no target evidence; project registry " + f"unavailable ({type(exc).__name__})")} + + return {"target_project": None, "project_exists": None, + "evidence": "no target evidence for this working directory"} + + +def awareness_status(project: str) -> dict: + """Readback: what does Rune durably know about this project?""" + rel = rune_relationship(project) + registry_entry = None + registry_error = "" + try: + from rune.project_registry import ProjectRegistry + registry_entry = ProjectRegistry(store_path()).get(project) + except Exception as exc: # noqa: BLE001 — reported, not swallowed + registry_error = f"{type(exc).__name__}" + return { + "project": project, + "relationship": rel["relationship"], + "evidence": rel["evidence"], + "registry_error": registry_error, + "registered": registry_entry is not None, + "registry": registry_entry, + } diff --git a/src/rune/construction_authority_cli.py b/src/rune/construction_authority_cli.py new file mode 100755 index 00000000..a84edc95 --- /dev/null +++ b/src/rune/construction_authority_cli.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""construction_authority_cli.py — JSON bridge for the MiMo scaffolding guard. + +The harness half of the T23 enforcement seam is +`config/mimo-hooks/scaffolding-guard.ts`, which runs in the MiMo (bun) +process and cannot import Rune. This CLI is the thin, deterministic adapter +between them — the same pattern `builder_bridge_cli.py` established for the +existing Rune Core preflight edge. + +It holds NO logic of its own: every decision is delegated to +`rune.construction_authority` (which in turn derives from the canonical +construction store). Keep it that way — a second implementation here would +be exactly the parallel construction system T23 §36 forbids. + +Usage +----- + # payload on stdin (what the hook uses) + echo '{"op":"check","tool":"write","args":{"filePath":"src/rune/x.py"},"cwd":"/repo"}' \ + | construction_authority_cli.py + + # convenience flags + construction_authority_cli.py status [--cwd DIR] + construction_authority_cli.py check --tool write --path src/rune/x.py + construction_authority_cli.py begin --text "build me a provider manager" + +Output: a single JSON object on stdout. Always exits 0 — the caller (a +harness hook) must never be broken by this bridge; failures are reported +inside the JSON as {"ok": false, "error": ...} so the hook can decide +(and so a failure can never silently become a denial). +""" + +from __future__ import annotations + +import json +import os +import sys + +_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if _REPO not in sys.path: + sys.path.insert(0, os.path.join(_REPO, "src")) +if _REPO not in sys.path: + sys.path.append(_REPO) + + +def _op_status(payload: dict) -> dict: + from rune.construction_authority import authority + return {"ok": True, "op": "status", "authority": authority(payload.get("cwd"))} + + +def _op_check(payload: dict) -> dict: + from rune.construction_authority import check_mutation, paths_for_tool + tool = str(payload.get("tool") or "") + args = payload.get("args") or {} + paths = payload.get("paths") + if paths is None: + paths = paths_for_tool(tool, args) + decision = check_mutation(tool, list(paths), payload.get("cwd")) + decision["op"] = "check" + decision["ok"] = True + return decision + + +def _op_begin(payload: dict) -> dict: + from rune.construction_authority import ( + PROJECT_ID, store_path, target_evidence) + from rune.construction_activation import activate_from_text + ev = target_evidence(payload.get("cwd")) + rec = activate_from_text( + str(payload.get("text") or ""), + store=store_path(payload.get("cwd")), + project=PROJECT_ID, + builder="mimo", + origin=str(payload.get("origin") or "mimo"), + task_id=payload.get("task_id"), + conversation=payload.get("conversation") or [], + target_project=ev["target_project"], + project_exists=ev["project_exists"], + ) + rec["target_evidence"] = ev + rec["op"] = "begin" + rec["ok"] = True + return rec + + +def _op_declare(payload: dict) -> dict: + from rune.construction_authority import declare_relationship + out = declare_relationship( + str(payload.get("project") or ""), + str(payload.get("relationship") or ""), + evidence=list(payload.get("evidence") or []), + note=str(payload.get("note") or "")) + out["op"] = "declare" + out["ok"] = True + return out + + +def _op_awareness(payload: dict) -> dict: + from rune.construction_authority import awareness_status + out = awareness_status(str(payload.get("project") or "")) + out["op"] = "awareness" + out["ok"] = True + return out + + +_OPS = { + "status": _op_status, + "check": _op_check, + "begin": _op_begin, + "declare": _op_declare, + "awareness": _op_awareness, +} + + +def _payload_from_argv(argv: list[str]) -> dict: + """Flag form, for humans and for tests.""" + payload: dict = {"op": argv[0] if argv else "status", "paths": []} + i = 1 + while i < len(argv): + key, _, val = argv[i].partition("=") + val = val or (argv[i + 1] if i + 1 < len(argv) else "") + if key in ("--tool", "tool"): + payload["tool"] = val + elif key in ("--path", "path"): + payload.setdefault("paths", []).append(val) + elif key in ("--cwd", "cwd"): + payload["cwd"] = val + elif key in ("--text", "text"): + payload["text"] = val + elif key in ("--project", "project"): + payload["project"] = val + elif key in ("--relationship", "relationship"): + payload["relationship"] = val + i += 2 + return payload + + +def main() -> int: + try: + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + except (OSError, ValueError): + raw = "" + try: + payload = json.loads(raw) if raw.strip() else _payload_from_argv(sys.argv[1:]) + except ValueError as exc: + print(json.dumps({"ok": False, "error": f"invalid payload: {exc}"})) + return 0 + if not isinstance(payload, dict): + print(json.dumps({"ok": False, "error": "payload must be an object"})) + return 0 + + op = str(payload.get("op") or "status") + handler = _OPS.get(op) + if handler is None: + print(json.dumps({"ok": False, "error": f"unknown op: {op}"})) + return 0 + try: + print(json.dumps(handler(payload), default=str)) + except Exception as exc: # noqa: BLE001 — the bridge must never throw + print(json.dumps({"ok": False, "op": op, + "error": f"{type(exc).__name__}: {exc}"})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/rune/rune_operator.py b/src/rune/rune_operator.py index 6533cd58..c865d31e 100644 --- a/src/rune/rune_operator.py +++ b/src/rune/rune_operator.py @@ -821,102 +821,28 @@ class RuneOperator: decides mode"). A turn the intent layer resolves as DISCUSSION is not a construction demand, so no construction state is recorded for it.""" - from .construction_mode import classify_mode, _STANDALONE_WORDS - from .construction_activation import activate - # §15J case F: manual override feeds the SAME decision path — a - # leading "/scaffold-mode " (or "scaffold-mode: ") - # marker is stripped and passed as explicit_override with - # override_authority="user". NOT a second implementation: - # classification still runs through classify_mode with provenance. - override = None - m = re.match( - r"^(?:/scaffold-mode|scaffold-mode:)\s+" - r"(NOT_CONSTRUCTION|LOCAL_CHANGE|EXISTING_PROJECT_CHANGE|" - r"PROJECT_BUILD|UNKNOWN)\s+(.*)$", - description, re.DOTALL) - if m: - override = m.group(1) - description = m.group(2).strip() - low = description.lower() - tokens = set(__import__("re").split(r"[^a-z0-9]+", low)) - # §12D: the NATIVE hook must not force the Rune target — standalone - # language means a NEW project (evidenced non-existence); explicit - # Rune references mean the existing authority; otherwise UNKNOWN. - if "rune" in tokens: - target, exists = "rune", True - elif _contains_any := [w for w in _STANDALONE_WORDS if w in low]: - target, exists = None, False # evidenced: standalone = new - elif "existing" in tokens: - target, exists = "rune", True - else: - target, exists = None, None # unknown → probes, not invention - mode_decision = classify_mode( - description, target_project=target, project_exists=exists, - affected_surface=[], - explicit_override=override, - override_authority="user" if override else None) - from .scaffold import Scaffold - s = Scaffold(self._construction_store(), "issue161-harness") - doc = s.load() - # §17J criterion 1: meaning gate BEFORE the classifier. - # Context = the operator's own canonical conversation window - # (no new state store); the current message is excluded so it - # is not double-recorded as its own referent. - try: - from .construction_intent import ( - ConversationContext, resolve_construction_intent) - ctx = ConversationContext() - for _m in self.conversation_window(channel, n=8): - _txt = str(_m.get("content", "")) - if _txt and _txt != description: - ctx.record_turn(str(_m.get("role", "user")), _txt) - _intent = resolve_construction_intent(description, ctx) - except Exception as exc: # noqa: BLE001 — intent layer - # unavailable: PRESERVE the pre-existing behaviour exactly - # (classify+activate) rather than silently changing - # governance in an unproven direction. - _intent = None + # T23 §36 (no parallel construction system): the meaning→classify + # →activate implementation lives in ONE place — + # `construction_activation.activate_from_text` — and is shared with + # the MiMo-surface route, so the TUI/operator path and the agent + # mutation path cannot drift apart. + from .construction_activation import activate_from_text + rec = activate_from_text( + description, + store=self._construction_store(), + project="issue161-harness", + builder="rune-native", + origin="operator", + task_id=task_id, + conversation=[ + {"role": str(_m.get("role", "user")), + "content": str(_m.get("content", ""))} + for _m in self.conversation_window(channel, n=8)], + ) + if rec.get("intent_error"): self._construction_note( - f"intent layer unavailable ({exc}); " + f"intent layer unavailable ({rec['intent_error']}); " f"classifier path unchanged") - # An explicit user override is USER AUTHORITY and outranks the - # meaning gate (§15J case F: the override is already carried with - # user provenance into the SAME classify_mode call). - if (override is None and _intent is not None - and _intent.action_intent == "DISCUSSION"): - # The intent layer resolved this turn as DISCUSSION: it is not a - # construction demand, so no construction state is created. The - # record keeps the SAME canonical observation kind/shape every - # other input produces (mode vocabulary unchanged — DISCUSSION - # maps to NOT_CONSTRUCTION, "no construction state"), with the - # intent evidence retained so the verdict is auditable. - doc.setdefault("observations", []).append({ - "kind": "construction_activated", - "task": task_id, - "mode": "NOT_CONSTRUCTION", - "activated": False, - "lifecycle": "", - "classification_evidence": _intent.classification_evidence[:3], - "intent_layer": "DISCUSSION", - "observed_at": __import__("datetime").datetime.utcnow() - .strftime("%Y-%m-%dT%H:%M:%SZ")}) - s.save(doc) - return - rec = activate(mode_decision, need=type("N", (), { - "objective": description, - "project": "rune", - "need_id": f"native-{task_id}", - "affected_surface": []})(), scaffold=s, builder="rune-native") - doc.setdefault("observations", []).append({ - "kind": "construction_activated", - "task": task_id, - "mode": mode_decision.mode, - "activated": rec.get("activated", False), - "lifecycle": rec.get("lifecycle", ""), - "classification_evidence": mode_decision.evidence[:3], - "observed_at": __import__("datetime").datetime.utcnow() - .strftime("%Y-%m-%dT%H:%M:%SZ")}) - s.save(doc) def send_task(self, description: str, model: str | None = None, channel: str = OPERATOR_CHANNEL) -> Dict[str, Any]: """Send a task to a model. diff --git a/store/spine/current.json b/store/spine/current.json index 5e8b88be..909e9f6d 100644 --- a/store/spine/current.json +++ b/store/spine/current.json @@ -2,7 +2,7 @@ "authority_generations": { "ast-import-projector": { "generation": 0, - "last_success": "2026-09-23T16:16:24Z", + "last_success": "2026-09-24T07:52:05Z", "observed": [ "consumed_by|module:abstraction|module:memory_consolidation", "consumed_by|module:abstraction|module:memory_selector", @@ -29,9 +29,7 @@ "consumed_by|module:cognitive_budgeter|module:tiered_executor", "consumed_by|module:cognitive_loop|module:rune_tui", "consumed_by|module:construction_activation|module:rune_operator", - "consumed_by|module:construction_intent|module:rune_operator", "consumed_by|module:construction_interface|module:builder_bridge", - "consumed_by|module:construction_mode|module:rune_operator", "consumed_by|module:context_broker|module:builder_bridge", "consumed_by|module:context_broker|module:memory_selector", "consumed_by|module:context_broker|module:rune_operator", @@ -278,8 +276,6 @@ "depends_on|module:recall_uplift|module:memory_env", "depends_on|module:rune_operator|module:capability_broker", "depends_on|module:rune_operator|module:construction_activation", - "depends_on|module:rune_operator|module:construction_intent", - "depends_on|module:rune_operator|module:construction_mode", "depends_on|module:rune_operator|module:context_broker", "depends_on|module:rune_operator|module:event_bus", "depends_on|module:rune_operator|module:evidence_probe", @@ -373,7 +369,7 @@ }, "deploy-identity-projector": { "generation": 1, - "last_success": "2026-09-23T16:16:24Z", + "last_success": "2026-09-24T07:52:05Z", "observed": [ "deployed_as|module:__main__|deployment:current", "deployed_as|module:abstraction|deployment:current", @@ -535,7 +531,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -564,7 +560,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code", "self-model" @@ -584,7 +580,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -602,7 +598,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -619,7 +615,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -636,7 +632,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -653,7 +649,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -670,7 +666,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -687,7 +683,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -704,7 +700,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -721,7 +717,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -737,7 +733,7 @@ }, "kind": "module", "maturity": "PRESENT", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -755,7 +751,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -772,7 +768,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -789,7 +785,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -804,7 +800,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -818,7 +814,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -833,7 +829,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -848,7 +844,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -862,7 +858,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -876,7 +872,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -891,7 +887,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -906,7 +902,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -921,7 +917,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -935,7 +931,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -949,7 +945,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -963,7 +959,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -978,7 +974,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -993,7 +989,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1007,7 +1003,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1021,7 +1017,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1035,7 +1031,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1049,7 +1045,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1064,7 +1060,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1079,7 +1075,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1094,7 +1090,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1109,7 +1105,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1118,14 +1114,14 @@ ], "id": "deployment:current", "identities": { - "deployed_at": "2026-09-20T10:26:06Z", - "deployed_sha": "1bc1cdeaf03a", - "deployed_version": "0.88.2", + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", "observed_from": "/mnt/workdrive/rune-code/current" }, "kind": "deployment_surface", "maturity": "LIVE_PROVEN", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -1141,7 +1137,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1159,7 +1155,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1177,7 +1173,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1195,7 +1191,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1213,7 +1209,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1231,7 +1227,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1249,7 +1245,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1267,7 +1263,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1285,7 +1281,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1303,7 +1299,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1321,7 +1317,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1339,7 +1335,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1357,7 +1353,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1375,7 +1371,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1393,7 +1389,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1411,7 +1407,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1429,7 +1425,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1447,7 +1443,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1465,7 +1461,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1483,7 +1479,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1501,7 +1497,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1519,7 +1515,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1537,7 +1533,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1555,7 +1551,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1573,7 +1569,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1591,7 +1587,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1608,7 +1604,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1626,7 +1622,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1644,7 +1640,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1662,7 +1658,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1679,7 +1675,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1697,7 +1693,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1715,7 +1711,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1732,7 +1728,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1749,7 +1745,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1767,7 +1763,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1784,7 +1780,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1802,7 +1798,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1819,7 +1815,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1836,7 +1832,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1853,7 +1849,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1869,7 +1865,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1885,7 +1881,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1901,7 +1897,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1921,7 +1917,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1939,7 +1935,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1957,7 +1953,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1975,7 +1971,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -1993,7 +1989,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2011,7 +2007,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2029,7 +2025,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2047,7 +2043,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2065,7 +2061,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2083,7 +2079,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2101,7 +2097,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2119,7 +2115,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2136,7 +2132,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2153,7 +2149,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2170,7 +2166,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2188,7 +2184,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2205,7 +2201,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2222,7 +2218,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2240,7 +2236,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2258,7 +2254,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2276,7 +2272,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2294,7 +2290,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2311,7 +2307,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2329,7 +2325,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2346,7 +2342,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2363,7 +2359,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2381,7 +2377,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2399,7 +2395,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2417,7 +2413,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2434,7 +2430,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2453,7 +2449,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2471,7 +2467,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2488,7 +2484,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2505,7 +2501,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2523,7 +2519,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2541,7 +2537,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2559,7 +2555,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2577,7 +2573,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2595,7 +2591,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2613,7 +2609,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2631,7 +2627,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2649,7 +2645,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2667,7 +2663,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2685,7 +2681,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2703,7 +2699,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2721,7 +2717,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2739,7 +2735,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2757,7 +2753,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2775,7 +2771,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2793,7 +2789,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2811,7 +2807,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2829,7 +2825,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2847,7 +2843,7 @@ }, "kind": "module", "maturity": "PRESENT", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -2862,7 +2858,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2878,7 +2874,7 @@ }, "kind": "module", "maturity": "PRESENT", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2895,7 +2891,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2911,7 +2907,7 @@ }, "kind": "module", "maturity": "PRESENT", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2927,7 +2923,7 @@ }, "kind": "module", "maturity": "PRESENT", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2944,7 +2940,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2960,7 +2956,7 @@ }, "kind": "module", "maturity": "PRESENT", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2977,7 +2973,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -2993,7 +2989,7 @@ }, "kind": "module", "maturity": "WIRED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [] }, { @@ -3007,7 +3003,7 @@ }, "kind": "module", "maturity": "PRESENT", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -3023,7 +3019,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -3039,7 +3035,7 @@ }, "kind": "module", "maturity": "EXERCISED", - "observed_at": "2026-09-23T16:16:24Z", + "observed_at": "2026-09-24T07:52:05Z", "tags": [ "code" ] @@ -3316,8 +3312,40 @@ "tags": [] } ], - "generated_at": "2026-09-23T16:16:24Z", + "generated_at": "2026-09-24T07:52:05Z", "observations": [ + { + "authority": "ast-import-projector", + "from": "module:construction_intent", + "kind": "fact_staled", + "observed_at": "2026-09-24T07:33:43Z", + "to": "module:rune_operator", + "type": "consumed_by" + }, + { + "authority": "ast-import-projector", + "from": "module:construction_mode", + "kind": "fact_staled", + "observed_at": "2026-09-24T07:33:43Z", + "to": "module:rune_operator", + "type": "consumed_by" + }, + { + "authority": "ast-import-projector", + "from": "module:rune_operator", + "kind": "fact_staled", + "observed_at": "2026-09-24T07:33:43Z", + "to": "module:construction_intent", + "type": "depends_on" + }, + { + "authority": "ast-import-projector", + "from": "module:rune_operator", + "kind": "fact_staled", + "observed_at": "2026-09-24T07:33:43Z", + "to": "module:construction_mode", + "type": "depends_on" + }, { "authority": "cli-invocation", "detail": "AttributeError in save(): string path not coerced \u2014 real defect surfaced by the change", @@ -6055,6 +6083,78 @@ "observed_at": "2026-09-23T16:16:24Z", "observed_from": "/mnt/workdrive/rune-code/current" }, + { + "deployed_at": "2026-09-20T10:26:06Z", + "deployed_sha": "1bc1cdeaf03a", + "deployed_version": "0.88.2", + "kind": "deployed_identity", + "observed_at": "2026-09-23T17:10:46Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, + { + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", + "kind": "deployed_identity", + "observed_at": "2026-09-24T07:33:43Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, + { + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", + "kind": "deployed_identity", + "observed_at": "2026-09-24T07:33:53Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, + { + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", + "kind": "deployed_identity", + "observed_at": "2026-09-24T07:34:03Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, + { + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", + "kind": "deployed_identity", + "observed_at": "2026-09-24T07:34:24Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, + { + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", + "kind": "deployed_identity", + "observed_at": "2026-09-24T07:34:40Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, + { + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", + "kind": "deployed_identity", + "observed_at": "2026-09-24T07:36:39Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, + { + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", + "kind": "deployed_identity", + "observed_at": "2026-09-24T07:43:48Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, + { + "deployed_at": "2026-09-23T17:28:09Z", + "deployed_sha": "c9095f5af56f", + "deployed_version": "0.88.3", + "kind": "deployed_identity", + "observed_at": "2026-09-24T07:52:05Z", + "observed_from": "/mnt/workdrive/rune-code/current" + }, { "deployed_sha": "2731db03f4bd", "deployed_version": "0.66.33", @@ -6146,7 +6246,7 @@ ], "from": "module:construction_intent", "observed_at": "2026-09-23T16:15:43Z", - "status": "CURRENT", + "status": "STALE", "to": "module:rune_operator", "type": "consumed_by" }, @@ -6179,7 +6279,7 @@ ], "from": "module:construction_mode", "observed_at": "2026-09-14T04:56:08Z", - "status": "CURRENT", + "status": "STALE", "to": "module:rune_operator", "type": "consumed_by" }, @@ -6216,17 +6316,6 @@ "to": "module:construction_activation", "type": "depends_on" }, - { - "authority": "ast-import-projector", - "evidence_refs": [ - "ast-import:rune_operator" - ], - "from": "module:rune_operator", - "observed_at": "2026-09-14T04:56:08Z", - "status": "CURRENT", - "to": "module:construction_mode", - "type": "depends_on" - }, { "authority": "ast-import-projector", "evidence_refs": [ @@ -6238,6 +6327,17 @@ "to": "module:scaffold", "type": "depends_on" }, + { + "authority": "ast-import-projector", + "evidence_refs": [ + "ast-import:rune_operator" + ], + "from": "module:rune_operator", + "observed_at": "2026-09-14T04:56:08Z", + "status": "STALE", + "to": "module:construction_mode", + "type": "depends_on" + }, { "authority": "ast-import-projector", "evidence_refs": [ @@ -6245,7 +6345,7 @@ ], "from": "module:rune_operator", "observed_at": "2026-09-23T16:15:43Z", - "status": "CURRENT", + "status": "STALE", "to": "module:construction_intent", "type": "depends_on" }, diff --git a/tests/test_construction_authority.py b/tests/test_construction_authority.py new file mode 100644 index 00000000..029bab0d --- /dev/null +++ b/tests/test_construction_authority.py @@ -0,0 +1,360 @@ +"""T23 — Scaffolding as the agent's default, enforceable construction path. + +Covers the Rune-side construction authority (src/rune/construction_authority.py), +the shared contextual entry (construction_activation.activate_from_text), the +JSON bridge, and the harness guard's own contract. + +The load-bearing tests are: + * test_declared_scope_blocks_out_of_scope — the tranche §60 negative proof; + * test_provisional_scope_never_blocks — proportionality (§12), the + guard must not punish legitimate multi-area work; + * test_harness_internal_envelope_is_not_construction — a live-measured + defect: actor notifications were minting contracts and blocking work. +""" + +from __future__ import annotations + +import json +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")) + +from rune.construction_activation import activate_from_text # noqa: E402 +from rune.construction_authority import ( # noqa: E402 + CORE, EXTERNAL, UNRELATED, authority, awareness_status, check_mutation, + declare_relationship, rune_relationship, seal_scope, target_evidence, +) + +PROJECT = "issue161-harness" + + +@pytest.fixture() +def store(tmp_path, monkeypatch): + """An isolated construction store — the real one is never touched.""" + s = tmp_path / "scaffold" + s.mkdir() + monkeypatch.setenv("RUNE_CONSTRUCTION_STORE", str(s)) + return s + + +def begin(text, store): + return activate_from_text( + text, store=str(store), project=PROJECT, builder="test", + origin="test", task_id="t", target_project="rune", + project_exists=True) + + +# -------------------------------------------------------------------------- +# Contextual trigger — measured against real classifier behaviour +# -------------------------------------------------------------------------- + +# Discussion / negation / proposal must NEVER create construction state. +# (The canonical intent layer is the meaning gate; T22 wired it ahead of the +# ONE classifier and these cases prove that gate holds on this route too.) +NO_CONSTRUCTION = [ + "explain how this module works", + "how would you build X?", + "what would changing the persistence layer involve?", + "don't build anything yet, just inspect it", + "could we potentially build X later?", + "change this constant from 5 to 6", + "rename this local variable", + "correct this import", +] + +# Real construction must be recognised and mint a governing contract. +CONSTRUCTION = [ + "build a new media manager", + "create a provider service", + "implement the next Memory subsystem", + "make a desktop app", + "add account switching across backend and UI", + "replace persistence with the new architecture", + "implement the new provider manager", +] + + +@pytest.mark.parametrize("text", NO_CONSTRUCTION) +def test_discussion_creates_no_construction_state(text, store): + rec = begin(text, store) + assert rec["mode"] == "NOT_CONSTRUCTION" + assert rec["contract_id"] is None + assert authority()["active"] is False + + +@pytest.mark.parametrize("text", CONSTRUCTION) +def test_construction_mints_a_governing_contract(text, store): + rec = begin(text, store) + assert rec["mode"] == "EXISTING_PROJECT_CHANGE" + assert rec["contract_id"], "a governed build must resolve ONE contract" + auth = authority() + assert auth["active"] is True + assert auth["phase"] == "building" + + +def test_local_change_stays_lightweight(store): + """§12 proportionality: a bounded local fix must not get a contract.""" + rec = begin("fix this typo", store) + assert rec["mode"] == "LOCAL_CHANGE" + assert rec["contract_id"] is None + assert authority()["active"] is False + + +def test_explicit_override_outranks_the_meaning_gate(store): + """`/scaffold-mode` is USER AUTHORITY (§15J case F).""" + rec = begin("/scaffold-mode EXISTING_PROJECT_CHANGE explain the module", + store) + assert rec["mode"] == "EXISTING_PROJECT_CHANGE" + + +def test_unknown_evidence_does_not_invent_construction(store): + """§30: insufficient evidence must not become BUILD_NEW. The classifier + may surface UNKNOWN (probes) or the intent layer may resolve the turn as + DISCUSSION — both mean NO construction state is invented.""" + rec = activate_from_text( + "do something about the thing", store=str(store), project=PROJECT, + builder="test", origin="test", target_project=None, project_exists=None) + assert rec["mode"] in ("UNKNOWN", "NOT_CONSTRUCTION") + assert rec["contract_id"] is None + assert authority()["active"] is False + + +# -------------------------------------------------------------------------- +# Enforcement — the tranche §60 negative proof +# -------------------------------------------------------------------------- + + +def test_no_contract_allows_mutation(store): + d = check_mutation("write", ["src/rune/anything.py"]) + assert d["allow"] is True + assert d["mode"] == "NO_CONTRACT" + + +def test_declared_scope_blocks_out_of_scope(store): + """THE NEGATIVE PROOF: scope says src/rune/scaffold.py + spine.py; + an attempt on another file is BLOCKED, not silently allowed.""" + begin("refactor src/rune/scaffold.py and src/rune/spine.py", store) + assert authority()["scope"] == ["src/rune/scaffold.py", "src/rune/spine.py"] + + ok = check_mutation("write", ["src/rune/spine.py"]) + assert ok["allow"] is True and ok["mode"] == "IN_SCOPE" + + bad = check_mutation("write", ["src/rune/other/unsafe.py"]) + assert bad["allow"] is False + assert bad["mode"] == "OUT_OF_SCOPE" + assert bad["out_of_scope"] == ["src/rune/other/unsafe.py"] + assert bad["contract_id"] + assert "OUT OF SCOPE" in bad["reason"] + # The reason must tell the model the lawful way forward. + assert "scope change" in bad["reason"] + + +def test_provisional_scope_never_blocks(store): + """A build whose need states no scope must not be punished for working + across areas — the working set accumulates instead (live-measured + rationale: blocking here turned one mis-classified turn into a block on + the operator's real work).""" + begin("implement the new provider manager", store) + for path in ("src/rune/a.py", "tests/test_a.py", "docs/a.md"): + d = check_mutation("write", [path]) + assert d["allow"] is True, path + assert set(authority()["scope"]) == {"src/rune", "tests", "docs"} + + +def test_sealed_scope_enforces(store): + """Sealing promotes a provisional scope to enforced.""" + rec = begin("implement the new provider manager", store) + check_mutation("write", ["src/rune/a.py"]) # provisional contact + seal_scope(rec["contract_id"]) + assert check_mutation("write", ["src/rune/b.py"])["allow"] is True + assert check_mutation("write", ["docs/b.md"])["allow"] is False + + +def test_shell_drift_is_governed(store): + """§20: the shell must not be an unrestricted bypass.""" + begin("refactor src/rune/scaffold.py", store) + d = check_mutation("bash", ["src/rune/other/x.py"]) + assert d["allow"] is False and d["mode"] == "OUT_OF_SCOPE" + + +def test_read_only_tools_are_not_gated(store): + """§12: reads cannot mutate and must never be blocked.""" + from rune.construction_authority import MUTATING_TOOLS + for tool in ("read", "glob", "grep", "list"): + assert tool not in MUTATING_TOOLS + assert check_mutation("read", ["/etc/passwd"])["allow"] is True + + +def test_outside_repo_paths_are_recorded_not_blocked(store): + begin("refactor src/rune/scaffold.py", store) + d = check_mutation("write", ["/tmp/scratch.py"]) + assert d["allow"] is True + assert d["checked"][0]["verdict"] == "outside_repo" + + +def test_terminal_contract_is_not_active(store): + """T22 repair preserved: an abandoned contract is not active authority.""" + from rune.scaffold import Scaffold + + rec = begin("implement the new provider manager", store) + s = Scaffold(str(store), PROJECT) + doc = s.load() + s.transition_contract(doc, rec["contract_id"], "abandoned", + evidence_ref="test") + s.save(doc) + assert authority()["active"] is False + assert check_mutation("write", ["src/anything.py"])["mode"] == "NO_CONTRACT" + + +# -------------------------------------------------------------------------- +# Rune-essential classification + durable awareness +# -------------------------------------------------------------------------- + + +def test_rune_itself_is_core(): + assert rune_relationship("rune")["relationship"] == CORE + + +def test_unrelated_project_is_not_absorbed(store): + r = rune_relationship("some-random-tool") + assert r["relationship"] == UNRELATED + assert awareness_status("some-random-tool")["registered"] is False + + +def test_declared_external_dependency_is_distinguished(store, monkeypatch, + tmp_path): + """§42: an external dependency Rune relies on must be recorded as a + dependency — never falsely absorbed into Rune core.""" + monkeypatch.setattr("rune.construction_authority.repo_root", + lambda: tmp_path) + out = declare_relationship("helper-daemon", EXTERNAL, + evidence=["Rune's recovery supervisor calls it"]) + assert out["relationship"] == EXTERNAL + recorded = json.loads( + (tmp_path / "store" / "rune_model" + / "rune_relationships.json").read_text()) + assert recorded["projects"]["helper-daemon"]["relationship"] == EXTERNAL + assert rune_relationship("helper-daemon")["relationship"] == EXTERNAL + + +def test_declare_rejects_invalid_relationship(store): + with pytest.raises(ValueError): + declare_relationship("x", "MADE_UP") + + +def test_target_evidence_resolves_the_rune_repo(): + ev = target_evidence(str(ROOT)) + assert ev["target_project"] == "rune" and ev["project_exists"] is True + + +# -------------------------------------------------------------------------- +# The JSON bridge the harness calls +# -------------------------------------------------------------------------- + + +def run_cli(payload, store): + proc = subprocess.run( + [sys.executable, str(ROOT / "src" / "rune" + / "construction_authority_cli.py")], + input=json.dumps(payload), capture_output=True, text=True, timeout=60, + env={"PATH": "/usr/bin:/bin", "RUNE_CONSTRUCTION_STORE": str(store), + "PYTHONPATH": str(ROOT / "src")}) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_cli_check_allows_without_contract(store): + out = run_cli({"op": "check", "tool": "write", + "args": {"filePath": "src/rune/x.py"}}, store) + assert out["ok"] is True and out["allow"] is True + + +def test_cli_check_blocks_out_of_scope(store): + run_cli({"op": "begin", "text": "refactor src/rune/scaffold.py", + "cwd": str(ROOT)}, store) + out = run_cli({"op": "check", "tool": "write", + "args": {"filePath": "src/other/x.py"}}, store) + assert out["allow"] is False and out["mode"] == "OUT_OF_SCOPE" + + +def test_cli_never_throws_on_bad_input(store): + out = run_cli({"op": "nonsense"}, store) + assert out["ok"] is False and "error" in out + + +def test_cli_reports_bridge_failure_in_band(store): + """A bridge failure must be visible in the JSON, never a crash — the + hook decides policy (fail-closed only for a known-active contract).""" + out = run_cli({"op": "check", "tool": "write", "args": {}}, store) + assert out["ok"] is True + + +# -------------------------------------------------------------------------- +# Harness guard: the live-measured internal-envelope defect +# -------------------------------------------------------------------------- + +GUARD = ROOT / "config" / "mimo-hooks" / "scaffolding-guard.ts" + +needs_node = pytest.mark.skipif(shutil.which("node") is None, + reason="node not available") + + +@needs_node +def test_harness_internal_envelope_is_not_construction(store, tmp_path): + """MEASURED DEFECT (T23, live): the turn boundary minted bogus contracts + from `` envelopes and then blocked the operator's own + work against them. The guard must skip harness-internal traffic.""" + mjs = tmp_path / "guard.mjs" + mjs.write_text(GUARD.read_text()) + script = tmp_path / "t.mjs" + script.write_text(f""" +import guard from "{mjs}" +await guard["experimental.chat.messages.transform"]({{}}, {{messages:[ + {{info:{{role:"user"}},parts:[{{type:"text", + text:"\\nsub-session completed\\nResult: implement src/rune/x.py"}}]}}]}}) +const out = {{args:{{filePath:"src/rune/construction_mode.py"}}}} +await guard["tool.execute.before"]({{tool:"write",sessionID:"s",callID:"c"}}, out) +console.log(JSON.stringify({{cancelled: out.cancel === true}})) +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + timeout=120, + env={"PATH": "/usr/bin:/bin", + "RUNE_CONSTRUCTION_STORE": str(store)}) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout.strip().splitlines()[-1]) == {"cancelled": False} + + +@needs_node +def test_guard_blocks_out_of_scope_through_the_hook(store, tmp_path): + """End-to-end: the harness hook itself cancels an out-of-scope mutation.""" + mjs = tmp_path / "guard.mjs" + mjs.write_text(GUARD.read_text()) + script = tmp_path / "t.mjs" + script.write_text(f""" +import guard from "{mjs}" +await guard["experimental.chat.messages.transform"]({{}}, {{messages:[ + {{info:{{role:"user"}},parts:[{{type:"text", + text:"refactor src/rune/scaffold.py and src/rune/spine.py"}}]}}]}}) +const ok = {{args:{{filePath:"src/rune/spine.py"}}}} +await guard["tool.execute.before"]({{tool:"edit",sessionID:"s",callID:"c"}}, ok) +const bad = {{args:{{filePath:"src/other/unsafe.py"}}}} +await guard["tool.execute.before"]({{tool:"write",sessionID:"s",callID:"c"}}, bad) +console.log(JSON.stringify({{inScope: ok.cancel === true, + outScope: bad.cancel === true, + reason: String(bad.cancelReason).slice(0,40)}})) +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + timeout=120, + env={"PATH": "/usr/bin:/bin", + "RUNE_CONSTRUCTION_STORE": str(store)}) + assert proc.returncode == 0, proc.stderr + got = json.loads(proc.stdout.strip().splitlines()[-1]) + assert got["inScope"] is False + assert got["outScope"] is True + assert got["reason"].startswith("OUT OF SCOPE")