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:
1 parent
a5da6e061d
commit
15c8d369da
11 files changed
+2365
-267
No files matched your search
@@ -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
|
||||
@@ -224,6 +224,8 @@
|
||||
"capability_ledger",
|
||||
"cognitive_budgeter",
|
||||
"construction_activation",
|
||||
"construction_authority",
|
||||
"construction_authority_cli",
|
||||
"construction_commands",
|
||||
"construction_decision",
|
||||
"construction_depth",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.**
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Executable
+165
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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")
|
||||
Reference in new issue
Block a user