feat(scaffolding): T23 — make Scaffolding the agent's default, enforceable construction path

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 <actor-notification> 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.
This commit is contained in:
conductor committed 2026-09-24 08:52:41 +01:00
1 parent a5da6e061d
commit 15c8d369da
11 files changed
+2365 -267

No files matched your search

+242
View File
@@ -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 `<actor-notification>` 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 `<system-reminder>` / 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
+2
View File
@@ -224,6 +224,8 @@
"capability_ledger",
"cognitive_budgeter",
"construction_activation",
"construction_authority",
"construction_authority_cli",
"construction_commands",
"construction_decision",
"construction_depth",
+213
View File
@@ -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<void>
```
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.
+239
View File
@@ -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<void>
```
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 `<actor-notification>` 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 `<system-reminder>`, 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.**
+1 -1
View File
@@ -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:
+205 -1
View File
@@ -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 <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:
+647
View File
@@ -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,
}
+165
View File
@@ -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())
+20 -94
View File
@@ -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 <MODE>" (or "scaffold-mode: <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.
+271 -171
View File
File diff suppressed because it is too large. Load diff
+360
View File
@@ -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 `<actor-notification>` 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:"<actor-notification>\\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")