Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f96055b69 | ||
|
|
923a21ba0b | ||
|
|
6348386869 | ||
|
|
55002ad838 | ||
|
|
6684289a8f | ||
|
|
e25ad0f964 | ||
|
|
c9dee14c62 | ||
|
|
fe9d154778 | ||
|
|
2a4f9c8367 | ||
|
|
7ddfa7eee7 | ||
|
|
986b450bc2 | ||
|
|
b51afea0cb | ||
|
|
c15840514f | ||
|
|
2833175329 | ||
|
|
4a06d9f11a | ||
|
|
c707a81933 | ||
|
|
37deb6085c | ||
|
|
affe852ff7 | ||
|
|
e1a71efeb9 | ||
|
|
5bd8cca941 | ||
|
|
fd5f31bba2 | ||
|
|
8a2a4f7b71 | ||
|
|
b2d6802dba | ||
|
|
3ebbf9a9b1 | ||
|
|
193d81961d | ||
|
|
94ad81245b | ||
|
|
196cf193df | ||
|
|
f95b2a63df | ||
|
|
708871e511 | ||
|
|
18ef9a53e3 | ||
|
|
05edd68027 | ||
|
|
d166e83d55 | ||
|
|
98b4c58d36 | ||
|
|
f5c8b07d15 | ||
|
|
292c72ec3d | ||
|
|
0a436b2f97 | ||
|
|
2ba4114fce | ||
|
|
eccb446a3c | ||
|
|
5e6a397e7c | ||
|
|
87fad6ff8b | ||
|
|
87252705a8 | ||
|
|
4423367ddb | ||
|
|
f85c654d9d | ||
|
|
7417a8ca02 | ||
|
|
ecfa80a7b3 | ||
|
|
2a106e39a3 | ||
|
|
54cee39b95 | ||
|
|
c27d13d9ac | ||
|
|
dd5030407e | ||
|
|
ba0ec0ae31 | ||
|
|
4d410319ce | ||
|
|
09ec2b81ba | ||
|
|
577695a1a3 | ||
|
|
2fad565016 | ||
|
|
6b805eea4a | ||
|
|
4ecd99023f | ||
|
|
d3410671fd | ||
|
|
a4c099a9ca | ||
|
|
09ba25473a | ||
|
|
b7a71e69f1 | ||
|
|
a30464df1a | ||
|
|
0f80334be1 | ||
|
|
c09a42f10a | ||
|
|
13704bd315 | ||
|
|
56f96f0d60 | ||
|
|
e86da59cdb | ||
|
|
143e6095ed | ||
|
|
fe4bc43a83 | ||
|
|
83426c0e1c | ||
|
|
b5c9a3c04e | ||
|
|
cb10db1e78 | ||
|
|
1d466a6926 | ||
|
|
fa51b96ca7 | ||
|
|
5796caa30a | ||
|
|
5169e02610 | ||
|
|
cceb607d99 | ||
|
|
c692013441 | ||
|
|
11ebc50a55 | ||
|
|
08c782fe91 | ||
|
|
4bcae0bb10 | ||
|
|
a8d1b98332 | ||
|
|
74272842aa | ||
|
|
aa67119d55 | ||
|
|
9681baa318 | ||
|
|
8a6b051149 | ||
|
|
6bb5507d33 | ||
|
|
8692f1dc76 | ||
|
|
78d70def5f | ||
|
|
c9260ace18 | ||
|
|
2a7e9dcfcf | ||
|
|
e3263ef9b1 | ||
|
|
ce328a8080 | ||
|
|
4df1f6f23f | ||
|
|
7484e175be | ||
|
|
d84060cc02 | ||
|
|
b102225d4e | ||
|
|
f86b2d1eec | ||
|
|
307b0bf814 | ||
|
|
d776f286de | ||
|
|
621f88c425 | ||
|
|
ef6811ff58 | ||
|
|
a4f02f227c | ||
|
|
5b315ac7c7 | ||
|
|
90bd2b3285 | ||
|
|
1bb4ea86b8 | ||
|
|
f98416e94a | ||
|
|
b3465176f8 | ||
|
|
aab5159ccc | ||
|
|
80f10b9be7 | ||
|
|
9d75e51b22 | ||
|
|
b6113fad7b | ||
|
|
f765365d03 | ||
|
|
b94a3aef61 | ||
|
|
8ca4628694 | ||
|
|
535c290917 | ||
|
|
6bc31c513e | ||
|
|
386137dc14 | ||
|
|
f87adc0d18 | ||
|
|
ed3a73a0a9 | ||
|
|
600008482d | ||
|
|
4bfa4f9b0a | ||
|
|
1d6213cd74 | ||
|
|
9c3f5f0a1d | ||
|
|
c0594ce192 | ||
|
|
dc0ac7b92e | ||
|
|
5e0a2def9d | ||
|
|
049a8dbd86 | ||
|
|
0a9b0648cd | ||
|
|
da2ec4df0e | ||
|
|
dcf992bb2f | ||
|
|
3dadf2d3f5 | ||
|
|
c8b317de53 | ||
|
|
ce330d3ac0 | ||
|
|
16b90fd8be | ||
|
|
c7a28e1db3 | ||
|
|
2731db03f4 | ||
|
|
eb2f1a3e0e | ||
|
|
e3b459dfdf | ||
|
|
b8d030e6c4 | ||
|
|
20a47fac86 | ||
|
|
36ee604646 | ||
|
|
20f493596d | ||
|
|
faf3575fb3 | ||
|
|
2e16fc4c89 | ||
|
|
12303bbd20 | ||
|
|
de30cd958e | ||
|
|
fd6e9b4bca | ||
|
|
24413e4228 | ||
|
|
407c23a689 | ||
|
|
982a9b07d5 | ||
|
|
ff506ebe55 | ||
|
|
2032819d99 | ||
|
|
e2464ae9d6 | ||
|
|
d7e04f1098 | ||
|
|
e639383c49 | ||
|
|
57a274d97d | ||
|
|
aac5295c24 | ||
|
|
9563148f9d | ||
|
|
76984c6362 | ||
|
|
4072d36b1a | ||
|
|
6d871abc9c | ||
|
|
ce40acbb1f | ||
|
|
1de28b55a0 | ||
|
|
75d6d7d62a | ||
|
|
7f47969128 | ||
|
|
a65e5d44de | ||
|
|
26a040892d | ||
|
|
1497c85cd4 | ||
|
|
a2463ad80b | ||
|
|
6965fc7375 | ||
|
|
0daad967c1 | ||
|
|
502317d8b6 | ||
|
|
07cdc2fc56 | ||
|
|
b4161a4034 | ||
|
|
e036f5f545 | ||
|
|
ab6c4f4e38 | ||
|
|
c520b024d1 | ||
|
|
1c73d809b9 | ||
|
|
34fc38d4ed | ||
|
|
a67a9c8520 | ||
|
|
b0efaffca1 | ||
|
|
a087a0b5e7 | ||
|
|
acb3ce0157 |
No files matched your search
@@ -213,38 +213,17 @@ jobs:
|
||||
env.write(f"RELEASE_VERSION={NEW}\n")
|
||||
env.write("AUTO_BUMPED=true\n")
|
||||
PYBUMP
|
||||
- name: Create release from merged pull request
|
||||
- name: Create evidence-derived release (SCAFFOLD-TRANCHE-009B)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
PR_BODY: ${{ gitea.event.pull_request.body }}
|
||||
RELEASE_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases
|
||||
LIST_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases?limit=50
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
# Skip idempotently if a release for this tag already exists (retry/replay safety).
|
||||
if curl --fail --silent "$LIST_API_URL" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c 'import json,sys,os; v=os.environ["RELEASE_VERSION"]; exists=[r for r in json.load(sys.stdin) if r.get("tag_name")=="v"+v]; sys.exit(0 if exists else 1)'; then
|
||||
echo "Release v$RELEASE_VERSION already exists — skipping creation"
|
||||
exit 0
|
||||
fi
|
||||
python3 - <<'PY' > /tmp/rune-release.json
|
||||
import json, os
|
||||
version = os.environ["RELEASE_VERSION"]
|
||||
body = f"## {os.environ['PR_TITLE']}\n\n{os.environ.get('PR_BODY', '')}"
|
||||
print(json.dumps({
|
||||
"tag_name": f"v{version}",
|
||||
"target_commitish": "master",
|
||||
"name": f"Rune v{version}",
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/rune-release.json \
|
||||
"$RELEASE_API_URL"
|
||||
rm -f /tmp/rune-release.json
|
||||
# ONE note-generation authority: the tested release_notes module.
|
||||
# The old PR-title/body paste path (which produced empty '##'
|
||||
# bodies on direct-push releases) is REMOVED — governed evidence
|
||||
# from the scaffold store is the source, not PR prose.
|
||||
python3 scripts/release_history_cli.py init \
|
||||
--version "$RELEASE_VERSION" \
|
||||
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
|
||||
--store store/scaffold/current.json
|
||||
+57
-8
@@ -286,12 +286,61 @@ jobs:
|
||||
run: |
|
||||
NEW="$(echo "${{ gitea.event.pull_request.head.ref }}" | sed 's|chore/auto-bump-||')"
|
||||
python3 scripts/check_version_surfaces.py check --root . --version "$NEW"
|
||||
- name: Publish protected-branch gate (full pipeline path)
|
||||
if: ${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump') }}
|
||||
- name: Publish protected-branch gate (full pipeline path — all needs green)
|
||||
# RELEASE-SAFETY FIX (2026-09-13): the previous version of this step
|
||||
# read /tmp/need_* files that no step ever writes and echoed success
|
||||
# UNCONDITIONALLY — required-checks published green while type-check
|
||||
# and unit-tests were red, and deploy promoted the red commits
|
||||
# (observed live: 24413e4, fd6e9b4, de30cd9 promoted 0.66.28/29 with
|
||||
# failing type-check). The verdict now comes from the needs context
|
||||
# itself: this step runs ONLY when every required job succeeded.
|
||||
if: >-
|
||||
${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump')
|
||||
&& needs.lint.result == 'success'
|
||||
&& needs['unit-tests'].result == 'success'
|
||||
&& needs.canary.result == 'success'
|
||||
&& needs['best-practices'].result == 'success'
|
||||
&& needs.documentation.result == 'success'
|
||||
&& needs['type-check'].result == 'success'
|
||||
&& needs['gitea-offline'].result == 'success' }}
|
||||
run: echo "All required CI checks passed"
|
||||
- name: Refuse promotion when any required check failed (fail-closed)
|
||||
# Companion to the step above: if anything is not exactly success
|
||||
# (failure, cancelled, skipped on a non-bump path), the required-checks
|
||||
# context reports FAILURE — deploy_qualify then refuses promotion.
|
||||
# Fail-closed: not verified → not deployable.
|
||||
if: >-
|
||||
${{ !startsWith(gitea.event.pull_request.head.ref, 'chore/auto-bump')
|
||||
&& (needs.lint.result != 'success'
|
||||
|| needs['unit-tests'].result != 'success'
|
||||
|| needs.canary.result != 'success'
|
||||
|| needs['best-practices'].result != 'success'
|
||||
|| needs.documentation.result != 'success'
|
||||
|| needs['type-check'].result != 'success'
|
||||
|| needs['gitea-offline'].result != 'success') }}
|
||||
run: |
|
||||
# All needs must have succeeded (skipped deps = this is a bump PR, handled above)
|
||||
FAILED=""
|
||||
for job in lint unit-tests canary best-practices documentation type-check gitea-offline; do
|
||||
RESULT=$(cat /tmp/need_$job 2>/dev/null || echo "")
|
||||
done
|
||||
echo "All required CI checks passed"
|
||||
echo "::error::Required CI checks not green — refusing to publish required-checks success"
|
||||
echo "lint=${{ needs.lint.result }} unit-tests=${{ needs['unit-tests'].result }} canary=${{ needs.canary.result }} best-practices=${{ needs['best-practices'].result }} documentation=${{ needs.documentation.result }} type-check=${{ needs['type-check'].result }} gitea-offline=${{ needs['gitea-offline'].result }}"
|
||||
exit 1
|
||||
|
||||
notify-event-bridge:
|
||||
# T16 §16C: post the CI completion WAKE to the local event bridge so
|
||||
# event-driven waiters (deploy.yml → ci_event_wait.py) resume on the
|
||||
# event instead of sleep-polling. ALWAYS runs (success or failure —
|
||||
# a red CI must wake the waiter just as loudly as a green one). The
|
||||
# wake is best-effort and never decides the verdict: the waiter
|
||||
# re-reads the canonical Gitea state via deploy_qualify.py.
|
||||
name: CI Pipeline / notify-event-bridge
|
||||
runs-on: rune-ci-desktop
|
||||
needs: [required-checks]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Wake event bridge (wake only — never completion truth)
|
||||
env:
|
||||
CI_SHA: ${{ gitea.sha }}
|
||||
CI_RESULT: ${{ needs.required-checks.result }}
|
||||
RUNE_EVENT_BRIDGE_URL: ${{ vars.RUNE_EVENT_BRIDGE_URL || 'http://127.0.0.1:8791' }}
|
||||
run: python3 scripts/notify_event_bridge.py
|
||||
+78
-17
@@ -19,38 +19,74 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Wait for CI qualification on this exact SHA
|
||||
- name: Wait for CI qualification on this exact SHA (event-driven, T16 §16C)
|
||||
# T16 §16C/§16D: the old sleep-30 → query → repeat loop is gone.
|
||||
# The waiter registers a correlation on the W/R event bridge and
|
||||
# yields in select() until ci.yml's notify-event-bridge job posts
|
||||
# the completion WAKE; the verdict is then re-read from canonical
|
||||
# Gitea (deploy_qualify.py). The 900s deadline is a FAILURE
|
||||
# DETECTOR (lost-event recovery), never the scheduler.
|
||||
env:
|
||||
GITEA_SERVER: ${{ gitea.server_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
# Shared bridge data root: the deploy step runs as the runner
|
||||
# user while the daemon runs as nick — the rendezvous state
|
||||
# (waits/events/resumes/leases) must be shared. §16C live
|
||||
# finding: defaulting to the invoking user's home made the
|
||||
# controller unable to create its FIFO (PermissionError) and
|
||||
# the deploy failed fast+truthfully on both 0.66.63 pushes.
|
||||
RUNE_WR_ROOT: /mnt/workdrive/wr-bridge
|
||||
RUNE_WR_CODE: /home/nick/.local/share/mimo-wait-resume
|
||||
run: |
|
||||
DEPLOY_SHA=$(git rev-parse HEAD)
|
||||
export DEPLOY_SHA
|
||||
echo "Qualifying $DEPLOY_SHA"
|
||||
DEADLINE=$((SECONDS + 900))
|
||||
while [ $SECONDS -lt $DEADLINE ]; do
|
||||
STATUS=$(python3 scripts/deploy_qualify.py)
|
||||
echo "required-checks: $STATUS"
|
||||
if [ "$STATUS" = "success" ]; then exit 0; fi
|
||||
if [ "$STATUS" = "failure" ]; then
|
||||
echo "::error::CI failed on $DEPLOY_SHA — deployment refused"
|
||||
exit 1
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
echo "::error::CI qualification timeout on $DEPLOY_SHA — deployment refused"
|
||||
exit 1
|
||||
echo "Qualifying $DEPLOY_SHA (event-driven)"
|
||||
# READINESS BEFORE ACTION (§16U): fail with a clear message if
|
||||
# the bridge rendezvous is not writable in THIS user's context,
|
||||
# instead of a stack trace at FIFO creation time.
|
||||
if ! touch /mnt/workdrive/wr-bridge/resumes/.write-probe 2>/dev/null; then
|
||||
echo "::error::RUNE_WR_ROOT not writable by $(id -un) — bridge data root misconfigured"
|
||||
exit 1
|
||||
fi
|
||||
rm -f /mnt/workdrive/wr-bridge/resumes/.write-probe
|
||||
python3 scripts/ci_event_wait.py --sha "$DEPLOY_SHA" --deadline 900
|
||||
|
||||
- name: Stage, verify, promote
|
||||
- name: Stage, verify, promote (monotonic)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
GITEA_SERVER: ${{ gitea.server_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
python3 scripts/deploy_stage.py
|
||||
|
||||
- name: Post-deploy runtime verification (the consuming path)
|
||||
- name: Promotion truthfulness check
|
||||
id: promotion_truth
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
CURRENT_TARGET=$(readlink /mnt/workdrive/rune-code/current || true)
|
||||
if [ "$CURRENT_TARGET" = "/mnt/workdrive/rune-code/${SHA:0:12}" ]; then
|
||||
echo "PROMOTED: current == candidate ($SHA)"
|
||||
echo "promoted=true" >> "$GITEA_OUTPUT"
|
||||
else
|
||||
# Not promoted. deploy_stage exit 0 + non-matching current =
|
||||
# NOOP_ALREADY_CURRENT or STALE_SUPERSEDED — truthful non-error
|
||||
# terminal states (a newer build already won). Do NOT claim
|
||||
# promotion; the runtime-verification step below only runs for
|
||||
# real promotions.
|
||||
echo "NOT PROMOTED: current -> $CURRENT_TARGET (superseded/noop — current is authoritative)"
|
||||
echo "promoted=false" >> "$GITEA_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post-deploy runtime verification (only for real promotions)
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
CURRENT_TARGET=$(readlink /mnt/workdrive/rune-code/current || true)
|
||||
if [ "$CURRENT_TARGET" != "/mnt/workdrive/rune-code/${SHA:0:12}" ]; then
|
||||
echo "SKIP runtime verification: this job did not promote (superseded/noop)"
|
||||
exit 0
|
||||
fi
|
||||
# Exactly what the launcher execs: cd <current>/src, import rune.
|
||||
RUNE_HOME=/mnt/workdrive/rune RUNE_MEMORY_DIR=/home/nick/.config/opencode/memory \
|
||||
/home/nick/.venvs/memory/bin/python3 -c "
|
||||
@@ -62,3 +98,28 @@ jobs:
|
||||
assert rune.__version__ == info['version']
|
||||
print('RUNTIME VERIFIED:', info)
|
||||
"
|
||||
- name: Release-history convergence fallback (SCAFFOLD-TRANCHE-0012C)
|
||||
# The PRIMARY convergence mechanism is deploy_stage.py's out-of-lock
|
||||
# publisher (publishes from CURRENT deployment authority, §14B/§14C).
|
||||
# This step is a non-blocking FALLBACK: if the primary publish failed
|
||||
# (e.g. missing Gitea config), this retries. NEVER fails the job —
|
||||
# documentation failure never outweighs deployment validity.
|
||||
if: ${{ success() && steps.promotion_truth.outputs.promoted == 'true' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
VERSION=$(python3 -c "import sys; sys.path.insert(0,'src'); import rune; print(rune.__version__)" 2>/dev/null || echo "")
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "SKIP: cannot determine version"
|
||||
exit 0
|
||||
fi
|
||||
python3 scripts/release_history_cli.py refresh \
|
||||
--version "$VERSION" \
|
||||
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
|
||||
--store "$GITHUB_WORKSPACE/store/scaffold/current.json" \
|
||||
--release-sha "$(git rev-parse HEAD)" \
|
||||
--deployed-sha "$(git rev-parse HEAD)" \
|
||||
|| echo "::warning::release-history fallback refresh FAILED — in-lock hook may have already converged; retry converges"
|
||||
@@ -22,9 +22,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run all canaries
|
||||
# T16 §16Q reconciliation: the glob looped shared/canary_*.py —
|
||||
# real canaries live in src/rune/ (canary_native.MODULES is the
|
||||
# registry authority). The old job silently no-opped.
|
||||
run: |
|
||||
FAILED=0
|
||||
for canary in shared/canary_*.py; do
|
||||
for canary in src/rune/canary_*.py; do
|
||||
if [ -f "$canary" ]; then
|
||||
echo "Running $canary..."
|
||||
python3 "$canary" || FAILED=$((FAILED + 1))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.66.17**
|
||||
**Current release: v0.66.68**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
+255
-221
@@ -1,224 +1,258 @@
|
||||
{
|
||||
"_comment": "Umbrella impact map \u2014 the change-propagation graph. Every src/rune/*.py and shared/*.py module MUST appear in exactly one umbrella's modules list (enforced by the umbrella_manifest_is_complete henxel; debt-tracked modules allowed via docs/module-coverage-debt.md). depends_on: when umbrella U changes, its dependents' canaries are required gates. mirrors: files that must travel together in the same PR (enforced by scripts/umbrella_impact.py unless the PR carries 'Propagation-waived: <reason>').",
|
||||
"umbrellas": {
|
||||
"memory": {
|
||||
"description": "Storing, retrieving, and managing knowledge",
|
||||
"modules": [
|
||||
"memory_store",
|
||||
"memory_consolidation",
|
||||
"memory_env",
|
||||
"shard_manager",
|
||||
"query_memory",
|
||||
"graph_memory",
|
||||
"warm_neuron_store",
|
||||
"abstraction",
|
||||
"cache",
|
||||
"recall_router",
|
||||
"drift_cache",
|
||||
"drift_compression",
|
||||
"tier_manager",
|
||||
"graph_shard_bridge",
|
||||
"memory_selector",
|
||||
"retrieval_benchmark"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_query_memory",
|
||||
"canary_memory_synthesis",
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
]
|
||||
},
|
||||
"governance": {
|
||||
"description": "Quality control, drift detection, promotion, audit",
|
||||
"modules": [
|
||||
"drift_core",
|
||||
"drift_observability",
|
||||
"drift_ledger",
|
||||
"audit_ledger",
|
||||
"curator",
|
||||
"claim_audit",
|
||||
"contracts",
|
||||
"build_pipeline_policy",
|
||||
"failure_auto_filer"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_schema_migration"
|
||||
],
|
||||
"depends_on": []
|
||||
},
|
||||
"identity": {
|
||||
"description": "Who the agent is \u2014 persona, voice, anchors",
|
||||
"modules": [
|
||||
"persona_definition",
|
||||
"persona_gate",
|
||||
"identity_anchors",
|
||||
"voice_hybrid",
|
||||
"voice_sync",
|
||||
"voicebox",
|
||||
"voice_injector",
|
||||
"behavior",
|
||||
"prompt_sync"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
]
|
||||
},
|
||||
"growth": {
|
||||
"description": "Learning and improvement",
|
||||
"modules": [
|
||||
"self_improve",
|
||||
"subconscious_pass",
|
||||
"goal",
|
||||
"elastic_cognition",
|
||||
"growth_delta",
|
||||
"autonomy_loop",
|
||||
"cognitive_loop",
|
||||
"dream_cycle",
|
||||
"growth_simulator"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory",
|
||||
"governance"
|
||||
]
|
||||
},
|
||||
"perception": {
|
||||
"description": "Input processing and context assembly",
|
||||
"modules": [
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"attention_capsule",
|
||||
"auto_context",
|
||||
"auto_tasks",
|
||||
"witness",
|
||||
"salience_gate",
|
||||
"cognitive_budgeter",
|
||||
"complexity_scorer"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_context_broker",
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
]
|
||||
},
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
"modules": [
|
||||
"capability_broker",
|
||||
"tool_executor",
|
||||
"tool_protocol",
|
||||
"local_tool_runtime",
|
||||
"openai_compat_transport",
|
||||
"forge_client",
|
||||
"message_queue",
|
||||
"connection_pool",
|
||||
"adaptive_router",
|
||||
"route_risk_flags",
|
||||
"evidence_probe",
|
||||
"builder_bridge",
|
||||
"builder_bridge_cli",
|
||||
"recall_uplift"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_p1_01",
|
||||
"canary_p0_05",
|
||||
"canary_native"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
]
|
||||
},
|
||||
"inference": {
|
||||
"description": "Model routing and outbound requests",
|
||||
"modules": [
|
||||
"model_routing",
|
||||
"model_profiles",
|
||||
"model_registry",
|
||||
"model_connect",
|
||||
"provider_registry",
|
||||
"provider_budget",
|
||||
"standardized_providers",
|
||||
"inference_trace",
|
||||
"response_adapter",
|
||||
"token_optimizer"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_p1_06",
|
||||
"canary_p1_02",
|
||||
"canary_resource_authority"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability"
|
||||
]
|
||||
},
|
||||
"orchestration": {
|
||||
"description": "Task lifecycle, operator, TUI presentation",
|
||||
"modules": [
|
||||
"rune_operator",
|
||||
"rune_tui",
|
||||
"rune_graphic",
|
||||
"rune_spinner",
|
||||
"rune_profiler",
|
||||
"chat_bubble",
|
||||
"message_widgets",
|
||||
"tui_validator",
|
||||
"microkernel",
|
||||
"event_bus",
|
||||
"event_envelope",
|
||||
"hook_engine",
|
||||
"system_registry",
|
||||
"cron_engine",
|
||||
"task_gate",
|
||||
"task_persist",
|
||||
"work_manager",
|
||||
"parallel_processor",
|
||||
"tiered_executor",
|
||||
"time_awareness",
|
||||
"night_mode",
|
||||
"pins",
|
||||
"progressive_escalation",
|
||||
"safety_override",
|
||||
"sleep_time",
|
||||
"verification_layer",
|
||||
"outcome_ledger",
|
||||
"warm_router",
|
||||
"subsystem_health",
|
||||
"build_mode",
|
||||
"dependency_tracker",
|
||||
"auto_detect",
|
||||
"build_orchestrator",
|
||||
"custodian",
|
||||
"gitea_auth",
|
||||
"gitea_pipeline",
|
||||
"gitea_provision",
|
||||
"umbrella_update"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_p1_07",
|
||||
"canary_orchestration_foundation",
|
||||
"canary_conversation_state",
|
||||
"canary_gitea_pipeline",
|
||||
"canary_tui_drift",
|
||||
"canary_cognitive_strata",
|
||||
"canary_surface_parity"
|
||||
],
|
||||
"depends_on": [
|
||||
"perception",
|
||||
"capability",
|
||||
"inference",
|
||||
"identity",
|
||||
"growth"
|
||||
]
|
||||
}
|
||||
"_comment": "GENERATED by rune.spine \u2014 compatibility view for legacy umbrella consumers. DO NOT EDIT: structural truth lives in store/spine/current.json; edits here are flagged by spine drift.",
|
||||
"_generated_by_spine": true,
|
||||
"generated_at": "2026-09-15T16:31:19Z",
|
||||
"umbrellas": {
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
"modules": [
|
||||
"adaptive_router",
|
||||
"builder_bridge",
|
||||
"builder_bridge_cli",
|
||||
"capability_broker",
|
||||
"connection_pool",
|
||||
"evidence_probe",
|
||||
"forge_client",
|
||||
"intent_continuity",
|
||||
"local_tool_runtime",
|
||||
"message_queue",
|
||||
"openai_compat_transport",
|
||||
"provider_context",
|
||||
"recall_uplift",
|
||||
"route_risk_flags",
|
||||
"tool_executor",
|
||||
"tool_protocol"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_native",
|
||||
"canary_p0_05",
|
||||
"canary_p1_01"
|
||||
]
|
||||
},
|
||||
"governance": {
|
||||
"description": "Quality control, drift detection, promotion, audit",
|
||||
"modules": [
|
||||
"audit_ledger",
|
||||
"build_pipeline_policy",
|
||||
"claim_audit",
|
||||
"contracts",
|
||||
"curator",
|
||||
"drift_core",
|
||||
"drift_ledger",
|
||||
"drift_observability",
|
||||
"failure_auto_filer"
|
||||
],
|
||||
"depends_on": [],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_schema_migration"
|
||||
]
|
||||
},
|
||||
"growth": {
|
||||
"description": "Learning and improvement",
|
||||
"modules": [
|
||||
"autonomy_loop",
|
||||
"cognitive_loop",
|
||||
"dream_cycle",
|
||||
"elastic_cognition",
|
||||
"goal",
|
||||
"growth_delta",
|
||||
"growth_simulator",
|
||||
"self_improve",
|
||||
"subconscious_pass"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance",
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
]
|
||||
},
|
||||
"identity": {
|
||||
"description": "Who the agent is \u2014 persona, voice, anchors",
|
||||
"modules": [
|
||||
"behavior",
|
||||
"identity_anchors",
|
||||
"persona_definition",
|
||||
"persona_gate",
|
||||
"prompt_sync",
|
||||
"voice_hybrid",
|
||||
"voice_injector",
|
||||
"voice_sync",
|
||||
"voicebox"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
]
|
||||
},
|
||||
"inference": {
|
||||
"description": "Model routing and outbound requests",
|
||||
"modules": [
|
||||
"inference_trace",
|
||||
"model_connect",
|
||||
"model_profiles",
|
||||
"model_registry",
|
||||
"model_routing",
|
||||
"provider_budget",
|
||||
"provider_registry",
|
||||
"response_adapter",
|
||||
"standardized_providers",
|
||||
"token_optimizer"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_p1_02",
|
||||
"canary_p1_06",
|
||||
"canary_resource_authority"
|
||||
]
|
||||
},
|
||||
"memory": {
|
||||
"description": "Storing, retrieving, and managing knowledge",
|
||||
"modules": [
|
||||
"abstraction",
|
||||
"cache",
|
||||
"drift_cache",
|
||||
"drift_compression",
|
||||
"graph_memory",
|
||||
"graph_shard_bridge",
|
||||
"memory_consolidation",
|
||||
"memory_env",
|
||||
"memory_selector",
|
||||
"memory_store",
|
||||
"query_memory",
|
||||
"recall_router",
|
||||
"retrieval_benchmark",
|
||||
"shard_manager",
|
||||
"tier_manager",
|
||||
"warm_neuron_store"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_memory_synthesis",
|
||||
"canary_query_memory"
|
||||
]
|
||||
},
|
||||
"orchestration": {
|
||||
"description": "Task lifecycle, operator, TUI presentation",
|
||||
"modules": [
|
||||
"auto_detect",
|
||||
"build_mode",
|
||||
"build_orchestrator",
|
||||
"chat_bubble",
|
||||
"cron_engine",
|
||||
"custodian",
|
||||
"dependency_tracker",
|
||||
"event_bus",
|
||||
"event_envelope",
|
||||
"gitea_auth",
|
||||
"gitea_pipeline",
|
||||
"gitea_provision",
|
||||
"hook_engine",
|
||||
"message_widgets",
|
||||
"microkernel",
|
||||
"night_mode",
|
||||
"outcome_ledger",
|
||||
"parallel_processor",
|
||||
"pins",
|
||||
"progressive_escalation",
|
||||
"rune_graphic",
|
||||
"rune_operator",
|
||||
"rune_profiler",
|
||||
"rune_spinner",
|
||||
"rune_tui",
|
||||
"safety_override",
|
||||
"sleep_time",
|
||||
"subsystem_health",
|
||||
"system_registry",
|
||||
"task_gate",
|
||||
"task_persist",
|
||||
"tiered_executor",
|
||||
"time_awareness",
|
||||
"tui_validator",
|
||||
"umbrella_update",
|
||||
"verification_layer",
|
||||
"warm_router",
|
||||
"work_manager"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability",
|
||||
"growth",
|
||||
"identity",
|
||||
"inference",
|
||||
"perception"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_cognitive_strata",
|
||||
"canary_conversation_state",
|
||||
"canary_gitea_pipeline",
|
||||
"canary_orchestration_foundation",
|
||||
"canary_p1_07",
|
||||
"canary_surface_parity",
|
||||
"canary_tui_drift"
|
||||
]
|
||||
},
|
||||
"perception": {
|
||||
"description": "Input processing and context assembly",
|
||||
"modules": [
|
||||
"attention_capsule",
|
||||
"auto_context",
|
||||
"auto_tasks",
|
||||
"capability_fit",
|
||||
"cognitive_budgeter",
|
||||
"complexity_scorer",
|
||||
"construction_activation",
|
||||
"construction_commands",
|
||||
"construction_decision",
|
||||
"construction_depth",
|
||||
"construction_interface",
|
||||
"construction_mode",
|
||||
"construction_pattern",
|
||||
"construction_patterns_builtin",
|
||||
"construction_projection",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"drift_resume",
|
||||
"event_wait",
|
||||
"failure_recovery",
|
||||
"layout_contract",
|
||||
"programme_continuation",
|
||||
"release_notes",
|
||||
"salience_gate",
|
||||
"scaffold",
|
||||
"spine",
|
||||
"stage_guards",
|
||||
"structural_store",
|
||||
"toolchain",
|
||||
"tui_backlog",
|
||||
"witness"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
],
|
||||
"mirrors": [],
|
||||
"canaries": [
|
||||
"canary_context_broker",
|
||||
"canary_full_system"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"_comment": "Umbrella impact map \u2014 the change-propagation graph. Every src/rune/*.py and shared/*.py module MUST appear in exactly one umbrella's modules list (enforced by the umbrella_manifest_is_complete henxel; debt-tracked modules allowed via docs/module-coverage-debt.md). depends_on: when umbrella U changes, its dependents' canaries are required gates. mirrors: files that must travel together in the same PR (enforced by scripts/umbrella_impact.py unless the PR carries 'Propagation-waived: <reason>').",
|
||||
"umbrellas": {
|
||||
"memory": {
|
||||
"description": "Storing, retrieving, and managing knowledge",
|
||||
"modules": [
|
||||
"memory_store",
|
||||
"memory_consolidation",
|
||||
"memory_env",
|
||||
"shard_manager",
|
||||
"query_memory",
|
||||
"graph_memory",
|
||||
"warm_neuron_store",
|
||||
"abstraction",
|
||||
"cache",
|
||||
"recall_router",
|
||||
"drift_cache",
|
||||
"drift_compression",
|
||||
"tier_manager",
|
||||
"graph_shard_bridge",
|
||||
"memory_selector",
|
||||
"retrieval_benchmark"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_query_memory",
|
||||
"canary_memory_synthesis",
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
]
|
||||
},
|
||||
"governance": {
|
||||
"description": "Quality control, drift detection, promotion, audit",
|
||||
"modules": [
|
||||
"drift_core",
|
||||
"drift_observability",
|
||||
"drift_ledger",
|
||||
"audit_ledger",
|
||||
"curator",
|
||||
"claim_audit",
|
||||
"contracts",
|
||||
"build_pipeline_policy",
|
||||
"failure_auto_filer"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_full_system",
|
||||
"canary_schema_migration"
|
||||
],
|
||||
"depends_on": []
|
||||
},
|
||||
"identity": {
|
||||
"description": "Who the agent is \u2014 persona, voice, anchors",
|
||||
"modules": [
|
||||
"persona_definition",
|
||||
"persona_gate",
|
||||
"identity_anchors",
|
||||
"voice_hybrid",
|
||||
"voice_sync",
|
||||
"voicebox",
|
||||
"voice_injector",
|
||||
"behavior",
|
||||
"prompt_sync"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"governance"
|
||||
]
|
||||
},
|
||||
"growth": {
|
||||
"description": "Learning and improvement",
|
||||
"modules": [
|
||||
"self_improve",
|
||||
"subconscious_pass",
|
||||
"goal",
|
||||
"elastic_cognition",
|
||||
"growth_delta",
|
||||
"autonomy_loop",
|
||||
"cognitive_loop",
|
||||
"dream_cycle",
|
||||
"growth_simulator"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory",
|
||||
"governance"
|
||||
]
|
||||
},
|
||||
"perception": {
|
||||
"description": "Input processing and context assembly",
|
||||
"modules": [
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"attention_capsule",
|
||||
"auto_context",
|
||||
"auto_tasks",
|
||||
"witness",
|
||||
"salience_gate",
|
||||
"cognitive_budgeter",
|
||||
"complexity_scorer"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_context_broker",
|
||||
"canary_full_system"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
]
|
||||
},
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
"modules": [
|
||||
"capability_broker",
|
||||
"tool_executor",
|
||||
"tool_protocol",
|
||||
"local_tool_runtime",
|
||||
"intent_continuity",
|
||||
"openai_compat_transport",
|
||||
"forge_client",
|
||||
"message_queue",
|
||||
"connection_pool",
|
||||
"adaptive_router",
|
||||
"route_risk_flags",
|
||||
"evidence_probe",
|
||||
"builder_bridge",
|
||||
"builder_bridge_cli",
|
||||
"recall_uplift"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_p1_01",
|
||||
"canary_p0_05",
|
||||
"canary_native"
|
||||
],
|
||||
"depends_on": [
|
||||
"memory"
|
||||
]
|
||||
},
|
||||
"inference": {
|
||||
"description": "Model routing and outbound requests",
|
||||
"modules": [
|
||||
"model_routing",
|
||||
"model_profiles",
|
||||
"model_registry",
|
||||
"model_connect",
|
||||
"provider_registry",
|
||||
"provider_budget",
|
||||
"standardized_providers",
|
||||
"inference_trace",
|
||||
"response_adapter",
|
||||
"token_optimizer"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_p1_06",
|
||||
"canary_p1_02",
|
||||
"canary_resource_authority"
|
||||
],
|
||||
"depends_on": [
|
||||
"capability"
|
||||
]
|
||||
},
|
||||
"orchestration": {
|
||||
"description": "Task lifecycle, operator, TUI presentation",
|
||||
"modules": [
|
||||
"rune_operator",
|
||||
"rune_tui",
|
||||
"rune_graphic",
|
||||
"rune_spinner",
|
||||
"rune_profiler",
|
||||
"chat_bubble",
|
||||
"message_widgets",
|
||||
"tui_validator",
|
||||
"microkernel",
|
||||
"event_bus",
|
||||
"event_envelope",
|
||||
"hook_engine",
|
||||
"system_registry",
|
||||
"cron_engine",
|
||||
"task_gate",
|
||||
"task_persist",
|
||||
"work_manager",
|
||||
"parallel_processor",
|
||||
"tiered_executor",
|
||||
"time_awareness",
|
||||
"night_mode",
|
||||
"pins",
|
||||
"progressive_escalation",
|
||||
"safety_override",
|
||||
"sleep_time",
|
||||
"verification_layer",
|
||||
"outcome_ledger",
|
||||
"warm_router",
|
||||
"subsystem_health",
|
||||
"build_mode",
|
||||
"dependency_tracker",
|
||||
"auto_detect",
|
||||
"build_orchestrator",
|
||||
"custodian",
|
||||
"gitea_auth",
|
||||
"gitea_pipeline",
|
||||
"gitea_provision",
|
||||
"umbrella_update"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_p1_07",
|
||||
"canary_orchestration_foundation",
|
||||
"canary_conversation_state",
|
||||
"canary_gitea_pipeline",
|
||||
"canary_tui_drift",
|
||||
"canary_cognitive_strata",
|
||||
"canary_surface_parity"
|
||||
],
|
||||
"depends_on": [
|
||||
"perception",
|
||||
"capability",
|
||||
"inference",
|
||||
"identity",
|
||||
"growth"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,3 +111,31 @@ subsystem): fetches live issue state, compares to
|
||||
- record lacking a required field
|
||||
|
||||
Exit 0 = consistent; exit 1 = drift listed. CI-eligible (read-only).
|
||||
|
||||
|
||||
## PL1 held-out era reconciliation (2026-09-12)
|
||||
|
||||
Fresh-session reconciliation of the ADAPT family against deployed 0.66.22
|
||||
@ 1497c85 + live PL1 held-out evidence (tasks task_2661189c9073,
|
||||
task_6e4885031355; ledger PE-9207213099-0270). Resolves the recorded tension:
|
||||
"ADAPT-LIVE complete → return target PL1" (the bounded tranche authorized
|
||||
2026-09-10 is complete) while numbered ADAPT issues remain open (the larger
|
||||
backlog was never in the ADAPT-LIVE tranche scope).
|
||||
|
||||
| # | state | evidence / basis |
|
||||
|---|---|---|
|
||||
| 284 | VALID_OPEN | epic tracker for the remaining sub-items below |
|
||||
| 292 | FIXED_VERIFIED (closed) | LOCAL_FIX fast path live (rune_operator.py:522,541); live-proven twice by the held-out tasks |
|
||||
| 293 | FIXED_VERIFIED (closed) | DESIGN-LITE implemented (rune_operator.py:660, cites #293), wired to STANDARD_CHANGE; suite+canary green |
|
||||
| 294 | DEFERRED_FUTURE | canonical remaining-obligations list ("ADAPT-5 budgets... for later epics") |
|
||||
| 295 | DEFERRED_FUTURE | canonical remaining-obligations list |
|
||||
| 296 | DEFERRED_FUTURE | canonical remaining-obligations list |
|
||||
| 297 | FIXED_VERIFIED (closed) | task_gate implemented (src/rune/task_gate.py) + broker wiring + canary registration |
|
||||
| 299 | DEFERRED_FUTURE | canonical remaining-obligations list |
|
||||
| 300 | VALID_OPEN | escalation mechanism exists (_probe_and_escalate); fresh deployed acceptance evidence pending |
|
||||
| 301 | DEFERRED_FUTURE | canonical remaining-obligations list (FINAL-12) |
|
||||
| 315 | VALID_OPEN | not started; ADAPT-1 sub-item (no canonical deferral authority) |
|
||||
| 316 | VALID_OPEN | not started; ADAPT-1 sub-item (no canonical deferral authority) |
|
||||
|
||||
Consistency checker: `scripts/gitea_reconciliation_check.py` → exit 0
|
||||
(72 records, 100 live issues in window) after these updates.
|
||||
@@ -54,7 +54,7 @@ This detour is complete when:
|
||||
|
||||
## RETURN TARGET / CURRENT: PL1 Process Learning — FIRST TRANCHE LIVE (2026-09-11)
|
||||
|
||||
PL1-01 structured Experience records: `scripts/pl1_records.py` (task_id, route resolved from route.jsonl, proof level, failure class, candidate status). PL1-02 failure classification in the PL0 vocabulary (research_dispatch, NOT_COMPLETED_NO_MUTATION_EVIDENCE, verification_failure, model_refusal, UNKNOWN). PL1-03 first learning loop: recurring prose-instead-of-mutation family → structural correction (route-driven instruction in the user context block) + replay contract (NOT_COMPLETED gate) — record PE-1789102658-0269 (SEED; held-out = next deployed mutation tasks must complete-with-evidence or fail honestly). PL1-04 promotion discipline: SEED→CANDIDATE→VALIDATED→PROMOTED via process_experience; structural fixes preferred. PL1-05 metrics: first_pass_accepted, mutation_failures, research_dispatches, false_completion_rate (0.0 — structurally prevented), candidates vs evidence-only split.
|
||||
PL1-01 structured Experience records: `scripts/pl1_records.py` (task_id, route resolved from route.jsonl, proof level, failure class, candidate status). PL1-02 failure classification in the PL0 vocabulary (research_dispatch, NOT_COMPLETED_NO_MUTATION_EVIDENCE, verification_failure, model_refusal, UNKNOWN). PL1-03 first learning loop: recurring prose-instead-of-mutation family → structural correction (route-driven instruction in the user context block) + replay contract (NOT_COMPLETED gate) — record PE-1789102658-0269 (SEED 2026-09-11 → VALIDATED 2026-09-12: two fresh deployed held-out mutation tasks completed-with-evidence — task_2661189c9073 + task_6e4885031355, LOCAL_FIX, Verboo-pinned, mutation + exit-code verification, ledger PE-9207213099-0270; substrate corrections 6965fc7 flat-ChatMessage contract + a2463ad resume transport selection applied between; caveat: corrections developer-applied structurally, not autonomous self-modification; PROMOTED requires repetition/soak). PL1-04 promotion discipline: SEED→CANDIDATE→VALIDATED→PROMOTED via process_experience; structural fixes preferred. PL1-05 metrics: first_pass_accepted, mutation_failures, research_dispatches, false_completion_rate (0.0 — structurally prevented), candidates vs evidence-only split.
|
||||
|
||||
Remaining ADAPT obligations for later epics: ADAPT-5 budgets, ADAPT-6 evidence capsule, ADAPT-7 DESIGN consolidation, ADAPT-10 legibility, FINAL-12 cross-track benchmark, D7 release-job-on-Pi.
|
||||
|
||||
@@ -177,3 +177,13 @@ Root causes (live-proven T15 Part F, 2026-09-11 evening):
|
||||
Live proof: experiment A (trigger d68fd1e, warranted patch) → exactly one
|
||||
bump commit a4afc77 (0.66.11), all five surfaces in one commit, queue
|
||||
drained, zero delayed duplicates, zero workflow failures.
|
||||
|
||||
## Local CI tooling (developer-side, NOT Rune runtime): W/R-v0.3 reactive wake (2026-09-12)
|
||||
|
||||
The desktop gitea-runner (v3.3.0) now runs `host.post_task_script` = `/usr/local/bin/gitea-wr-wake.sh`
|
||||
after every job: a sub-second fire-and-forget POST to the local W/R daemon (`127.0.0.1:8791/wake`),
|
||||
which sweeps waiting external waits through AUTHORITATIVE Gitea verification (bounded retry for the
|
||||
reporter-ack window; 60s reconciler demoted to fallback). Runner event ≠ success proof. Config:
|
||||
`/etc/gitea-desktop-runner/config.yaml` (backup: config.yaml.pre-wr03). Measured: terminal→verified
|
||||
wait transition ~2s (was 0-30s polling). INTERACTIVE_AUTO_RESUME remains NOT YET SAFE (no supported
|
||||
MiMo busy/idle signal) — headless-only dispatch, unchanged.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Scaffolding V1 — Tranche 1
|
||||
|
||||
_2026-09-12. Scaffolding is the controlled need-to-capability system: NEED →
|
||||
DISCOVERY → GAP → REUSE/ACQUIRE/BUILD → IMPACT+PLAN → SAFE IMPLEMENTATION →
|
||||
VERIFICATION → PROVISIONAL USE → TRUST/REVISE/RETIRE → REUSABLE CAPABILITY.
|
||||
Tranche 1 is deliberately narrower: make Rune stop rediscovering how Rune is
|
||||
built every time Rune changes._
|
||||
|
||||
## Tranche 1 contract
|
||||
|
||||
`scripts/scaffold_projection.py` — a PROJECTION over existing authorities,
|
||||
never a new one:
|
||||
|
||||
| question | authority projected |
|
||||
|---|---|
|
||||
| what subsystem owns X? | `config/umbrellas.json` (umbrella manifest + henxel-enforced completeness) |
|
||||
| what does X depend on? | AST import graph over `src/rune/*.py` (incl. relative/lazy imports) |
|
||||
| who consumes X? | reverse import edges |
|
||||
| what changes if X changes? | mirrors + `depends_on` umbrellas + consumers |
|
||||
| what proof obligations arise? | unit-test file, canary registry, umbrella gates, suite/lint/henxels |
|
||||
| which are satisfied? | filesystem + registry evidence (static); suite/ratchet/henxels are runtime-check commands |
|
||||
|
||||
Commands: `component <module>` · `impact <module>` · `obligations <module>` ·
|
||||
`all [outfile]`. If the projection disagrees with an authority, fix the
|
||||
authority — never the projection.
|
||||
|
||||
## Mechanism audit (2026-09-12, deployed 0.66.22)
|
||||
|
||||
| mechanism | verdict | basis |
|
||||
|---|---|---|
|
||||
| Umbrella manifest + impact map + propagation gate | KEEP | ownership/impact authority; henxel-enforced completeness; consumed by projection |
|
||||
| Henxels structural contract | KEEP | cross-cutting obligations (silent-handler ratchet, registries, coverage debt) |
|
||||
| Canary registry (`canary_native.MODULES` + per-track canaries) | KEEP | per-subsystem proof obligations; consumed by projection |
|
||||
| CI/deploy contract (required checks, TUI startup canary, atomic promote) | KEEP | deployment proof chain live-proven repeatedly |
|
||||
| Version surfaces + auto-version + semver | KEEP | bump fast lane + push convergence working (0.66.22 earned this session) |
|
||||
| Lint/pyright/coverage ratchets | KEEP | shrinking-baseline gates, all green |
|
||||
| Authority map + dead-subnet ledger (D6) | EVOLVE | static doc today; Scaffolding projections make it derivable; D6 retire-disposition still owed |
|
||||
| Issue reconciliation artifact + checker | KEEP | tracker truth with drift detection |
|
||||
| PL1 experience ledger | KEEP | the trust/revise/retire input for future capability crystallisation |
|
||||
| Intent continuity ledger | KEEP | PART 6 state machine, 7 invariants |
|
||||
| `MIMO_AUTOMATIC_RESUME` / W/R interactive injection | KEEP (bounded) | headless single-flight proven; interactive auto-resume NOT YET SAFE until a supported busy signal exists |
|
||||
| Duplicate authorities risk | REPLACE (avoided) | Scaffolding v1 adds NO new registry — projections only |
|
||||
|
||||
## Real-change proof (this tranche)
|
||||
|
||||
Change: context discovery hit Ollama's `/api/show` with cloud-pinned model
|
||||
names — `400 invalid model name` noise on every cloud task + 8192 misbudget
|
||||
for a 131072-window model (observed live in every PL1 Task A/B run).
|
||||
|
||||
1. BEFORE mutation: `scaffold_projection.py impact local_tool_runtime`
|
||||
derived — subsystem `capability`; consumers `rune_operator`; gates:
|
||||
full suite, lint ratchet, henxels, inference+orchestration canaries;
|
||||
unit-test obligation `tests/test_local_tool_runtime.py` (exists).
|
||||
2. Change made: `is_cloud_pinned` — THE provider-prefix rule, single
|
||||
authority in `openai_compat_transport.py`; consumed by context discovery
|
||||
(new) and transport selection (deduped); red-first regression tests.
|
||||
3. Obligations tracked: suite 1314 green · lint ratchet PASS · henxels exit 0
|
||||
· the projection itself caught two gaps mid-flight (relative imports
|
||||
hidden consumers; a broad except biting the lint ratchet) — both fixed at
|
||||
the source the projection pointed to.
|
||||
4. Deploy: normal release chain → deployed identity verified.
|
||||
|
||||
## Maturity (honest)
|
||||
|
||||
- Projection engine: **UNIT-PROVEN** + proven on one real change.
|
||||
- Not yet: CI integration of `scaffold_projection.py all` (drift detection),
|
||||
capability discovery / gap recognition / reuse-before-build / external
|
||||
acquisition / provisional-trust lifecycle — FOUNDATIONAL ONLY (architecture
|
||||
does not block them; the projection source-list is the extension surface).
|
||||
- Deferred with reasons recorded: none blocked by Tranche 1.
|
||||
|
||||
---
|
||||
|
||||
# Scaffolding V1 — Tranche 2: Construction Substrate (2026-09-13)
|
||||
|
||||
_Tranche 2 (contract SCAFFOLD-TRANCHE-001) built the construction substrate
|
||||
itself: a shared structural-store primitive, the Project Scaffold layer over
|
||||
it, and durable Construction Contracts — Rune's first build tracked by the
|
||||
system it builds._
|
||||
|
||||
## Architecture
|
||||
|
||||
src/rune/structural_store.py shared generic primitive — NO Rune constants
|
||||
↓ ↓
|
||||
src/rune/spine.py src/rune/scaffold.py
|
||||
(Rune Spine — canonical (Project Scaffold — canonical
|
||||
structural self-model) construction/structural truth
|
||||
about EXTERNAL projects)
|
||||
|
||||
Authority map (no duplicates):
|
||||
|
||||
| fact | authority | others |
|
||||
|---|---|---|
|
||||
| Rune's structure | `spine.py` + `store/spine/current.json` | scaffold only references |
|
||||
| external project structure | `scaffold.py` store | Spine holds bridge facts only |
|
||||
| content history / rollback | Git | both reference commits |
|
||||
| coordination (issues/PRs/CI) | Gitea | scaffold stores observations |
|
||||
| why work exists | Task/Intent | contracts reference task ids |
|
||||
|
||||
Construction Contract = ONE durable object (`contract:<id>`, kind
|
||||
`construction_contract`) with plain `phase`/`status` fields governed by a
|
||||
small legal-transition map (draft → approved → building → verifying →
|
||||
migrating → superseding → closed; failed/abandoned terminal-or-retry). No
|
||||
workflow engine. Resumability = store durability, proven across real
|
||||
process boundaries.
|
||||
|
||||
## Mechanics
|
||||
|
||||
- **Repo binding** (`bind_repo`): forge/owner/name/branch/root/placement as
|
||||
scaffold DATA. No secrets — schema rejects secret-named fields AND every
|
||||
`save()` recursively redacts credential-named keys (defense in depth in
|
||||
the shared core, so Spine inherits it too).
|
||||
- **Scope check** (`check_scope`): changed files vs approved scope paths →
|
||||
green / red / **unknown** (an unscoped contract is never silently green).
|
||||
Out-of-scope change = RED until explicitly amended; amendments recorded,
|
||||
original RED preserved in history.
|
||||
- **Predicted vs observed**: frontier snapshot at approval; observed impact
|
||||
captured at closure from INDEPENDENT evidence (git, tests) — never from
|
||||
re-walking the prediction's own graph. Missed entities become CANDIDATE
|
||||
corrections awaiting evidence.
|
||||
- **Drift**: authority-owned facts that fail re-observation go STALE —
|
||||
never silently fresh, never force-reverted. Reality wins.
|
||||
- **Bridge** (`spine_bridge_entity`): identity + repo surface only. Project
|
||||
internals NEVER leak into Rune Spine (test-enforced). Contract evidence
|
||||
may cite `spine://` URIs — citations are inert on the referenced
|
||||
authority.
|
||||
|
||||
## Real proof (this tranche on itself)
|
||||
|
||||
SCAFFOLD-TRANCHE-001 migrated from its pre-scaffold JSON into the new
|
||||
store, advanced through phases with evidence checkpoints, closed with
|
||||
predicted-vs-observed from git/test evidence, scope check caught the
|
||||
store's own runtime artifacts (RED → explicit amendment → green, history
|
||||
kept), and construction provenance attached into Rune Spine without
|
||||
duplicating authority. A fresh interpreter answers what/why/state/
|
||||
evidence/changed/next from the store alone — no transcripts.
|
||||
|
||||
## Maturity (honest)
|
||||
|
||||
| component | state |
|
||||
|---|---|
|
||||
| structural_store core | EXERCISED (unit-proven + real use) |
|
||||
| Rune Spine after refactor | LIVE_PROVEN behavior-neutral (68 tests + identical rebuild) |
|
||||
| Project Scaffold | EXERCISED |
|
||||
| Construction Contract | EXERCISED (resumability proven cross-process) |
|
||||
| repo binding (identity model) | UNIT-PROVEN (local git fixture) |
|
||||
| external-repo live proof | BLOCKED ON USER REPO SELECTION |
|
||||
| Scaffolding overall | substrate only — capability-fit discovery, gap decisions, toolchain selection, patterns, maintenance TUI all NOT BUILT (data seams exist) |
|
||||
|
||||
---
|
||||
|
||||
# Scaffolding Tranche 4 — Live Need → Decision → Construction Contract (2026-09-13)
|
||||
|
||||
_The adoption tranche: a real Need now flows automatically through the
|
||||
Tranche 2/3 mechanisms and produces a governed Construction Contract. The
|
||||
model no longer has to remember the procedure — it is durable Scaffolding._
|
||||
|
||||
## Live flow
|
||||
|
||||
NEED (reference to Task/Intent — pointer, never a copy)
|
||||
↓ normalization (gaps recorded, never invented)
|
||||
↓ structural discovery + fit + resolution (Spine + Project Scaffold)
|
||||
↓ toolchain decision (existing project toolchain wins)
|
||||
↓ layout obligations (USER-LOCKED authority inherited)
|
||||
↓ user-approval gate (material changes require approval; approved
|
||||
bounded work never re-asks)
|
||||
↓ DecisionTrace (durable, model-independent)
|
||||
↓ generated Construction Contract → existing build/verify pipeline
|
||||
|
||||
## Authority map (unchanged — the path CONNECTS, never duplicates)
|
||||
|
||||
| authority | owns |
|
||||
|---|---|
|
||||
| Task/Intent | why work exists |
|
||||
| Rune Spine | Rune structural truth |
|
||||
| Project Scaffold | external-project structural/construction truth |
|
||||
| Layout Contract | desired interface constraints (USER-LOCKED = user authority) |
|
||||
| Construction Contract | governed build obligations/history |
|
||||
| Git / Gitea | history / execution |
|
||||
|
||||
## Boundary honesty
|
||||
|
||||
- **ADAPT/research**: the live path records a research ROUTE
|
||||
(TARGETED_EVIDENCE_PROBE / FULL_RESEARCH_PIPELINE) when evidence is
|
||||
insufficient — a seam, NOT an executed pipeline call. No fake
|
||||
integration claimed.
|
||||
- **BUILD_NEW** is never a fallback: insufficient evidence blocks it;
|
||||
normalization gaps block it; merge precedence across authorities
|
||||
requires every consulted authority to agree.
|
||||
- **No second task engine**: one decision function + one trace object.
|
||||
|
||||
## Contract-quality repair (Tranche 3 defects)
|
||||
|
||||
Closure now distinguishes:
|
||||
|
||||
predicted_existing_affected planned changes to existing entities
|
||||
expected_new entities the build PLANNED to create
|
||||
correctly_predicted predicted AND independently observed
|
||||
predicted_but_unaffected predicted, NOT observed
|
||||
expected_new_observed planned creation happened
|
||||
expected_new_missing planned creation did NOT happen
|
||||
genuinely_unexpected_new observed, planned by neither list
|
||||
|
||||
Prediction cannot self-certify observation: the observed set comes ONLY
|
||||
from caller evidence (git/tests/build/deploy). Legacy keys (missed/
|
||||
needless/observed) preserved — old contracts readable without migration.
|
||||
|
||||
## Layout-obligation inheritance
|
||||
|
||||
A change touching a UI component/region/implementation inherits its Layout
|
||||
Contract as a build obligation. USER-LOCKED obligations state that changing
|
||||
placement requires explicit user authorization. Unrelated backend changes
|
||||
inherit nothing. USER-LOCKED drift is never reconciled by editing the
|
||||
contract — code adapts.
|
||||
|
||||
## Maturity (honest)
|
||||
|
||||
- Need intake: EXERCISED (real live proof)
|
||||
- decision orchestration: LIVE-PROVEN for the bounded live flow
|
||||
- capability discovery adoption: EXERCISED
|
||||
- fit/gap adoption: EXERCISED
|
||||
- toolchain adoption: EXERCISED
|
||||
- contract generation: LIVE-PROVEN (one real generated contract)
|
||||
- contract-quality prediction: EXERCISED (repaired semantics, one live closure)
|
||||
- layout-obligation inheritance: UNIT-PROVEN (fixtures) + real TUI binding
|
||||
- Scaffolding overall: adoption path live for one bounded flow — NOT
|
||||
general autonomous construction, NOT universal discovery/toolchain
|
||||
intelligence, NOT Scaffolding complete.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Scaffolding Tranche 9 — Release History + Evidence Correction (2026-09-13)
|
||||
|
||||
_Gitea Releases are now an evidence-derived human-readable history of
|
||||
Rune's evolution. Plus the Tranche 8 builder-evidence correction._
|
||||
|
||||
## Release semantics (audited)
|
||||
|
||||
| surface | meaning |
|
||||
|---|---|
|
||||
| VERSION | semver in pyproject/init (e.g. 0.66.44) |
|
||||
| TAG | v<VERSION>, created by auto-version at the BUMP commit |
|
||||
| release commit | the auto-bump SHA — NOT necessarily final state |
|
||||
| converged deployed identity | last commit deployed under the same VERSION |
|
||||
|
||||
Same-version convergence commits (closure/accounting/lock fixes landing and
|
||||
deploying after the bump) are normal; the notes record BOTH the tag commit
|
||||
and the final deployed identity, distinction exposed honestly.
|
||||
|
||||
## Mechanism
|
||||
|
||||
`release_notes.py`: deterministic evidence extraction from governed records
|
||||
(closed contracts, resolved maintenance, contract-quality records) →
|
||||
structured ReleaseNote → Markdown → Gitea API (POST create / PATCH update).
|
||||
Idempotent: publishing twice for a tag UPDATES, never duplicates. Notes are
|
||||
historical documentation — NOT Memory, NOT structural authority.
|
||||
|
||||
## T8 evidence correction (§1)
|
||||
|
||||
The T8 report claimed a close-from-verifying refusal + reactive superseding
|
||||
transition. Durable checkpoints show NO superseding transition and the
|
||||
phase map permits verifying→closed; live output showed first-attempt close
|
||||
success. The claim was RETRACTED with full provenance (original claim,
|
||||
correction, reason, evidence preserved in the contract record) and
|
||||
recorded as process-defect:unsupported-causal-claim. MiMo adoption
|
||||
maturity survives on the SUPPORTED evidence: scope constrained file edits,
|
||||
pattern obligations forced preservation proof, interface-reported
|
||||
observations, fresh resume.
|
||||
|
||||
## Maturity
|
||||
|
||||
Release-note generator: LIVE-PROVEN (real Gitea, 10 releases). Backfill:
|
||||
0.66.35→0.66.44 carrying historical-backfill provenance. Pre-0.66.35
|
||||
history: skipped (weak evidence — honest limitation).
|
||||
@@ -0,0 +1,71 @@
|
||||
# Rune Structural Map
|
||||
|
||||
_Maintained structural projection — the fast re-entry surface (T16 §16N).
|
||||
Companion to the Spine store (`store/spine/current.json`, structural
|
||||
authority) and `store/evidence/t16/16M_inventory.md` (full classification
|
||||
record). Update this map when authority or canonical routes change; do
|
||||
not copy the repo here._
|
||||
|
||||
## What is Rune?
|
||||
|
||||
A self-hosting coding agent: TUI client over an operator core, with a
|
||||
structural self-model (Spine), a construction authority (Scaffolding),
|
||||
an event-driven wait/resume bridge (W/R), a research pipeline, and a
|
||||
fail-closed release/deployment pipeline that builds, tests, releases,
|
||||
and deploys Rune itself to `/mnt/workdrive/rune-code/current`.
|
||||
|
||||
## Where things happen (canonical routes only)
|
||||
|
||||
| Concern | Authority | State |
|
||||
|---|---|---|
|
||||
| Structural self-model | `src/rune/spine.py` | `store/spine/current.json` |
|
||||
| Construction (build lifecycle) | `src/rune/scaffold.py` + `construction_*.py` | scaffold store (`<dir>/.rune/scaffold`) |
|
||||
| Builder sessions | `scripts/t15_builder_session.py` (event-driven successor; T14 is legacy) | contract + wait records |
|
||||
| Provider/model selection | `rune_operator` final decider; ambient context via `provider_context.require_model` | `state/model_routing_profiles.json` |
|
||||
| Wait/resume (any long op) | `src/rune/event_wait.py` + W/R daemon (out-of-repo snapshot at `~/.local/share/mimo-wait-resume`, data root `/mnt/workdrive/wr-bridge`) | waits/events/leases/resumes JSON |
|
||||
| CI qualification verdict | `scripts/deploy_qualify.py` (two legs, fail-closed) | Gitea commit status + runs |
|
||||
| Release/deploy | `scripts/deploy_stage.py` → `deploy_promotion.py` (monotonic, flock) | deploy root + `current`/`last-good` symlinks |
|
||||
| Research | `scripts/research_task_tree.py` (authority; labels are projection) | `research/issue-<N>/` |
|
||||
| Task completion truth | `verification_layer.py` + `orchestration/conversation_state.py` | durable event store + checkpoints |
|
||||
| Routing (task class) | `src/rune/adaptive_router.py` | `routes/route.jsonl` |
|
||||
| Canary registry | `src/rune/canary_native.MODULES` | observed by Spine |
|
||||
| Structural health | `henxels_checks.py` + `henxels.yaml` | repo root |
|
||||
|
||||
## Known non-canonical / duplicate routes (do not extend; reconcile via 16Q)
|
||||
|
||||
- `build_mode.BuildManager` (legacy /build) — superseded by builder_bridge
|
||||
+ event-driven sessions; still wired.
|
||||
- `scripts/t14_builder_session.py` — superseded by t15 event-driven driver.
|
||||
- Provider selection entry points ×5 (`model_routing.rank`,
|
||||
`model_connect route`, `model_registry get`, `provider_registry`,
|
||||
`work_manager`) — operator is the de-facto final decider.
|
||||
- Task-ish state ×4 (task-tool DB, `task_persist` persisted.json,
|
||||
`memory_store.working`, orchestration event store).
|
||||
- Maintenance ledgers ×3 (scaffold maintenance_item, tui_backlog, drift_ledger).
|
||||
|
||||
## Unfinished / blocked / deprecated
|
||||
|
||||
- programme_continuation.py: implemented + tested but UNWIRED (no
|
||||
production caller) — the "continue problem" machinery awaits routing.
|
||||
- maintenance.yml canary-suite job: loops `shared/canary_*.py` — wrong
|
||||
dir (real canaries in `src/rune/`), job likely no-ops (LEGACY).
|
||||
- daemon out-of-repo: the W/R daemon is deployed from
|
||||
`~/.local/share/mimo-wait-resume` with its own git snapshot —
|
||||
packaging debt, no CI coverage of the deployed copy.
|
||||
- Substrate: NOT BUILT. Scaffolding exposes the seam
|
||||
(`construction_projection.py`, `construction_commands.py`); mock
|
||||
client proves compatibility (`tests/test_substrate_seam.py`).
|
||||
|
||||
## Fresh-session quick answers
|
||||
|
||||
- Is a claim true? → Spine store + evidence_refs, never commit messages.
|
||||
- Is construction allowed? → scaffold contract phase + LEGAL_TRANSITIONS.
|
||||
- How does a long wait work? → register on the W/R bridge, yield on the
|
||||
FIFO event; watchdog is failure detection, never scheduling; the event
|
||||
is a WAKE — authority is re-read after resume.
|
||||
- Who picks the provider? → the user layer, via ambient context;
|
||||
construction never does (`test_provider_ownership.py` enforces).
|
||||
- What runs where? → CI on `rune-ci-desktop` runner; deploy stages to
|
||||
`/mnt/workdrive/rune-code/<sha>`; data home pinned at
|
||||
`/mnt/workdrive/rune` (RUNE_HOME); implementation worktree
|
||||
`/mnt/workdrive/issue161-harness`.
|
||||
@@ -41,8 +41,8 @@
|
||||
"perception"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "B3-v2 Qdrant associative-memory projection spike \u2014 not started; ADAPT-1 sub-item under epic #284"
|
||||
},
|
||||
{
|
||||
"number": 315,
|
||||
@@ -54,14 +54,14 @@
|
||||
"perception"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "B2-v2 wiring-status/architecture-truth report generator \u2014 not started; ADAPT-1 sub-item under epic #284"
|
||||
},
|
||||
{
|
||||
"number": 311,
|
||||
"title": "ADAPT-1 B2 (STANDARD-LIKE): wiring-status/architecture-truth report generator (#158)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "SUPERSEDED",
|
||||
"labels": [
|
||||
"infrastructure",
|
||||
"perception"
|
||||
@@ -74,14 +74,14 @@
|
||||
"number": 301,
|
||||
"title": "FINAL-12: Cross-track benchmark + adaptive provisional ratification",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "NEEDS_REVALIDATION",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"labels": [
|
||||
"growth",
|
||||
"perception"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "cross-track benchmark + adaptive provisional ratification \u2014 canonical remaining-obligations list"
|
||||
},
|
||||
{
|
||||
"number": 300,
|
||||
@@ -92,90 +92,90 @@
|
||||
"perception"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "escalation/downgrade honored at acceptance on the deployed runtime \u2014 probe_and_escalate exists; fresh live acceptance evidence pending"
|
||||
},
|
||||
{
|
||||
"number": 299,
|
||||
"title": "ADAPT-10: Harness/context legibility audit",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"labels": [
|
||||
"feature",
|
||||
"governance"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "harness/context legibility audit \u2014 canonical remaining-obligations list"
|
||||
},
|
||||
{
|
||||
"number": 297,
|
||||
"title": "ADAPT-8: Deterministic-first QUALIFY",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "FIXED_VERIFIED",
|
||||
"labels": [
|
||||
"memory"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": ""
|
||||
},
|
||||
{
|
||||
"number": 296,
|
||||
"title": "ADAPT-7: DESIGN consolidation experiment (APPLICABILITY + INNOVATION)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"labels": [
|
||||
"memory"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "DESIGN consolidation experiment (APPLICABILITY+INNOVATION) \u2014 canonical remaining-obligations list"
|
||||
},
|
||||
{
|
||||
"number": 295,
|
||||
"title": "ADAPT-6: Evidence capsule / context minimization",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"labels": [],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "evidence capsule / context minimization \u2014 canonical remaining-obligations list"
|
||||
},
|
||||
{
|
||||
"number": 294,
|
||||
"title": "ADAPT-5: Bound RESEARCHED_CHANGE work (budgets + stop conditions)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"reconciliation_state": "DEFERRED_FUTURE",
|
||||
"labels": [
|
||||
"feature"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "budgets + stop conditions for RESEARCHED_CHANGE \u2014 canonical 'remaining ADAPT obligations for later epics'"
|
||||
},
|
||||
{
|
||||
"number": 293,
|
||||
"title": "ADAPT-4: STANDARD_CHANGE path (DESIGN-LITE)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "FIXED_VERIFIED",
|
||||
"labels": [
|
||||
"feature"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": ""
|
||||
},
|
||||
{
|
||||
"number": 292,
|
||||
"title": "ADAPT-3: LOCAL_FIX fast path",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "FIXED_VERIFIED",
|
||||
"labels": [
|
||||
"perception"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": ""
|
||||
},
|
||||
{
|
||||
@@ -190,8 +190,8 @@
|
||||
"perception"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-12",
|
||||
"next_action": "epic tracker for remaining ADAPT sub-items (#294 #295 #296 #299 #300 #301 #315 #316)"
|
||||
},
|
||||
{
|
||||
"number": 259,
|
||||
@@ -1150,5 +1150,7 @@
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"last_reconciled": "2026-09-12",
|
||||
"reconciled_by": "PL1 held-out era reconciliation (fresh session 2026-09-12)"
|
||||
}
|
||||
@@ -9,6 +9,7 @@ new surface._
|
||||
|---|---|---|
|
||||
| `rune_tui.py` | The main Textual application — chat pane, work pane, status bar (version label + refresh), footer, dialogs | The known giant (file-size debt tracked in henxels); composition root for all UI |
|
||||
| `tui_validator.py` | Import-allowlist validation for TUI code | Drift defect tracked in #139 (allowlist vs `__future__`/relative imports) |
|
||||
| `tui_backlog.py` | Not a surface — the durable TUI Upgrade Backlog ledger (outstanding projection/control work per backend feature) | SCAFFOLD-TRANCHE-007; ledger only, never structural authority |
|
||||
|
||||
## Adding a TUI surface
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
settings:
|
||||
warn_about_similar_files:
|
||||
above: 0.85
|
||||
ignore: ["docs/review-lessons.yaml", "docs/v1/**", "docs/wiki/**"]
|
||||
ignore: ["docs/review-lessons.yaml", "docs/v1/**", "docs/wiki/**", "store/spine/**"]
|
||||
|
||||
henxels:
|
||||
- henxel: "Canaries live at the repo's canary slot and are registered in CI"
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"BLE001": 352,
|
||||
"S110": 80
|
||||
}
|
||||
"BLE001": 354,
|
||||
"S110": 80
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.66.17"
|
||||
version = "0.66.68"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"errorCount": 120,
|
||||
"warningCount": 465,
|
||||
"informationCount": 0
|
||||
"errorCount": 117,
|
||||
"warningCount": 443,
|
||||
"informationCount": 0
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ certifi==2026.7.22
|
||||
# httpx
|
||||
chardet==7.6.0
|
||||
# via diff-cover
|
||||
coverage==7.16.0
|
||||
coverage==7.16.1
|
||||
# via rune (pyproject.toml)
|
||||
diff-cover==10.5.1
|
||||
# via rune (pyproject.toml)
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ci_event_wait.py — T16 §16C/§16D: event-driven CI qualification wait.
|
||||
|
||||
Replaces the deploy.yml sleep-30 → query → repeat polling loop with the
|
||||
EXISTING W/R event bridge (src/rune/event_wait.py — the SAME primitive the
|
||||
builder adapter uses; §16E: one bridge, adapters differ only in identity
|
||||
and authority probe).
|
||||
|
||||
Flow (mandate §16C):
|
||||
|
||||
push / CI workflow starts
|
||||
↓
|
||||
register correlation (provider="gitea", event_type="ci_completed",
|
||||
run_ref="ci-<sha>", sha=<sha>)
|
||||
↓
|
||||
WAITING_FOR_EVENT (await_resume_event: select() on FIFO, no floor)
|
||||
↓
|
||||
ci.yml final job POSTs /process-event (the WAKE)
|
||||
↓
|
||||
daemon matches → resume_cmd fires → FIFO token → controller resumes
|
||||
↓
|
||||
canonical Gitea state INDEPENDENTLY re-read (deploy_qualify.py verdict)
|
||||
↓
|
||||
verdict
|
||||
|
||||
The event is a WAKE, never truth. The final verdict always comes from
|
||||
deploy_qualify.py (required-checks context + exact-SHA ci.yml run, both
|
||||
legs). Watchdog deadline is a FAILURE DETECTOR, never the scheduler:
|
||||
|
||||
- event arrives → authority re-read; pending → bounded re-arm until the
|
||||
overall deadline (ack-window semantics, same as the daemon's wake sweep).
|
||||
- event lost/suppressed → watchdog expires → ONE-SHOT authority query:
|
||||
terminal verdict → recovered truthfully (no invented wait);
|
||||
CI still running (non-terminal Gitea runs for the SHA) → re-arm;
|
||||
nothing alive and no verdict → truthful failure (deployment refused).
|
||||
|
||||
Exit codes: 0 = qualified (success), 1 = refused (failure/timeout).
|
||||
Output: one JSON line with the §16D latency-proof timestamps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_SCRIPT_DIR.parent / "src"))
|
||||
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
|
||||
REPO_ROOT = _SCRIPT_DIR.parent
|
||||
QUALIFY_SCRIPT = _SCRIPT_DIR / "deploy_qualify.py"
|
||||
|
||||
|
||||
def authority_verdict() -> str:
|
||||
"""ONE-SHOT canonical Gitea verdict via deploy_qualify.py — the only
|
||||
completion truth. Never parses the event payload."""
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(QUALIFY_SCRIPT)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env=dict(os.environ, PYTHONPATH=""),
|
||||
)
|
||||
out = (proc.stdout or "").strip().splitlines()
|
||||
return (out[-1] if out else "pending").strip()
|
||||
|
||||
|
||||
def authority_after_wake(max_tries: int = 3, gap_secs: int = 4) -> str:
|
||||
"""Post-wake authority read. The daemon's own wake sweep uses a
|
||||
bounded event-triggered retry (4s ×3) for the job-finished-microseconds-
|
||||
ago ack window; a verdict read here can hit the same window. This is a
|
||||
bounded schedule, not a polling loop — 3 reads max, then back to the
|
||||
event wait / watchdog."""
|
||||
verdict = "pending"
|
||||
for attempt in range(max_tries):
|
||||
verdict = authority_verdict()
|
||||
if verdict != "pending":
|
||||
return verdict
|
||||
if attempt < max_tries - 1:
|
||||
time.sleep(gap_secs)
|
||||
return verdict
|
||||
|
||||
|
||||
def ci_alive() -> bool:
|
||||
"""Liveness probe for THIS adapter: any non-terminal Actions run for
|
||||
the SHA in canonical Gitea (one-shot on watchdog expiry — not a poll).
|
||||
Import the probe from deploy_qualify to keep ONE API client."""
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import deploy_qualify as dq # noqa: E402 (spec-loaded scripts pattern)
|
||||
import gitea_auth
|
||||
server = os.environ.get("GITEA_SERVER", "").rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO", "")
|
||||
sha = os.environ.get("DEPLOY_SHA", "")
|
||||
if not (server and repo and sha):
|
||||
return False
|
||||
token = gitea_auth.get_token() if gitea_auth.has_token() else ""
|
||||
if not token:
|
||||
return False
|
||||
runs = dq._fetch_runs(server, repo, sha, token)
|
||||
if not runs:
|
||||
return False
|
||||
terminal = {"success", "failure", "cancelled", "skipped"}
|
||||
return any(str(r.get("status", "")).lower() not in terminal
|
||||
for r in runs)
|
||||
|
||||
|
||||
def run(*, sha: str, deadline_secs: int,
|
||||
watchdog_secs: int = 300,
|
||||
daemon_url: str = ew.DEFAULT_DAEMON_URL) -> int:
|
||||
op = f"ci-{sha}"
|
||||
t0 = time.time()
|
||||
# §16Y #10 race fix: the FIFO reader exists BEFORE registration, so a
|
||||
# wake arriving in the registration window can always be delivered.
|
||||
fifo_fd = ew.open_fifo_reader(op)
|
||||
# Event replay (T16 §16C live finding): on a serialized runner the
|
||||
# completion event usually arrives BEFORE the deploy job registers.
|
||||
# A matching durable event means the wake already happened — skip
|
||||
# the rendezvous entirely and go straight to the authority read.
|
||||
prior = ew.find_matching_event("gitea", "ci_completed", op)
|
||||
if prior is not None and prior.get("exit_code") is not None:
|
||||
rec = ew.register_process_wait(
|
||||
op, project="rune",
|
||||
objective=f"event-driven CI qualification for {sha}",
|
||||
watched_for="Gitea CI completion event (replayed from the "
|
||||
"durable event log)",
|
||||
provider="gitea", event_type="ci_completed", sha=sha,
|
||||
watchdog_secs=min(watchdog_secs, deadline_secs))
|
||||
registered_at = rec["created_at"]
|
||||
event_at = time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = event_at
|
||||
# the event is a WAKE, never truth — canonical authority decides
|
||||
verdict = authority_after_wake()
|
||||
if verdict == "pending" and not ci_alive():
|
||||
verdict = authority_verdict()
|
||||
mode = ("event_replayed" if verdict != "pending"
|
||||
else "authority_unresolved")
|
||||
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
out = {
|
||||
"sha": sha,
|
||||
"registered_at": registered_at,
|
||||
"event_received_at": event_at,
|
||||
"controller_resumed_at": resumed_at,
|
||||
"verdict_at": verdict_at,
|
||||
"mode": mode,
|
||||
"rearm_reason": "completion event replayed from durable log "
|
||||
"(arrived before registration)",
|
||||
"verdict": verdict,
|
||||
"wall_secs": round(time.time() - t0, 3),
|
||||
}
|
||||
print(json.dumps(out))
|
||||
return 0 if verdict == "success" else 1
|
||||
rec = ew.register_process_wait(
|
||||
op, project="rune",
|
||||
objective=f"event-driven CI qualification for {sha}",
|
||||
watched_for="Gitea CI completion event (wake; verdict re-read "
|
||||
"from canonical Gitea)",
|
||||
provider="gitea", event_type="ci_completed", sha=sha,
|
||||
watchdog_secs=min(watchdog_secs, deadline_secs))
|
||||
registered_at = rec["created_at"]
|
||||
deadline = time.monotonic() + deadline_secs
|
||||
verdict = "pending"
|
||||
mode = "event"
|
||||
event_at = ""
|
||||
resumed_at = ""
|
||||
rearm_reason = ""
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(1, int(deadline - time.monotonic()))
|
||||
result = ew.await_resume_event(
|
||||
op, watchdog_secs=min(remaining, watchdog_secs), pgid=None,
|
||||
fifo_fd=fifo_fd)
|
||||
if result["kind"] == "event":
|
||||
event_at = event_at or time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())
|
||||
# WAKE received — canonical authority decides, never the
|
||||
# event payload
|
||||
verdict = authority_after_wake()
|
||||
if verdict != "pending":
|
||||
mode = "event"
|
||||
break
|
||||
rearm_reason = "event seen; authority pending"
|
||||
if not ci_alive():
|
||||
# CI terminal in Gitea but aggregator lagging: one
|
||||
# final truthful read, then stop
|
||||
verdict = authority_verdict()
|
||||
mode = ("event" if verdict != "pending"
|
||||
else "authority_unresolved")
|
||||
break
|
||||
continue
|
||||
if result["kind"] == "eof":
|
||||
# writer gone without a token: the DURABLE record decides
|
||||
rec = ew.operation_wait(op) or {}
|
||||
if rec.get("status") in ("COMPLETED", "FAILED",
|
||||
"READY_TO_RESUME", "RESUMING") or \
|
||||
(rec.get("status") == "RESUME_UNVERIFIED"
|
||||
and rec.get("process_event_evidence")):
|
||||
event_at = event_at or time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())
|
||||
verdict = authority_after_wake()
|
||||
if verdict != "pending":
|
||||
mode = "event_recovered_from_durable_record"
|
||||
break
|
||||
rearm_reason = "eof without token; durable record pending"
|
||||
continue
|
||||
# watchdog expiry: adapter liveness (Gitea runs) decides
|
||||
# re-arm vs truthful recovery — failure detector, never
|
||||
# scheduler
|
||||
if ci_alive():
|
||||
rearm_reason = "watchdog expiry; CI runs non-terminal " \
|
||||
"in Gitea — re-arm"
|
||||
continue
|
||||
verdict = authority_verdict()
|
||||
if verdict == "pending":
|
||||
mode = "watchdog_lost_event_no_verdict"
|
||||
else:
|
||||
mode = "watchdog_authority_recovered"
|
||||
break
|
||||
finally:
|
||||
os.close(fifo_fd)
|
||||
if verdict == "pending":
|
||||
if mode == "event":
|
||||
mode = "deadline_pending"
|
||||
verdict = authority_verdict() # final one-shot truth
|
||||
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
out = {
|
||||
"sha": sha,
|
||||
"registered_at": registered_at,
|
||||
"event_received_at": event_at,
|
||||
"controller_resumed_at": resumed_at,
|
||||
"verdict_at": verdict_at,
|
||||
"mode": mode,
|
||||
"rearm_reason": rearm_reason,
|
||||
"verdict": verdict,
|
||||
"wall_secs": round(time.time() - t0, 3),
|
||||
}
|
||||
print(json.dumps(out))
|
||||
return 0 if verdict == "success" else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sha = os.environ.get("DEPLOY_SHA", "")
|
||||
args = sys.argv[1:]
|
||||
if "--sha" in args:
|
||||
sha = args[args.index("--sha") + 1]
|
||||
deadline = 900
|
||||
if "--deadline" in args:
|
||||
deadline = int(args[args.index("--deadline") + 1])
|
||||
if not sha:
|
||||
print(json.dumps({"error": "no sha (DEPLOY_SHA or --sha required)"}))
|
||||
return 1
|
||||
return run(sha=sha, deadline_secs=deadline)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,123 @@
|
||||
"""construction_guards.py — deterministic guards for observed process
|
||||
defects (SCAFFOLD-TRANCHE-006 §21-22).
|
||||
|
||||
Two real defect classes from recent sessions:
|
||||
|
||||
A. DURABLE-STORE MUTATION WITHOUT PERSISTENCE (Tranche 5: closure script
|
||||
mutated the contract in memory, never called save(); the next read saw
|
||||
stale state). Guard: a durable-boundary operation verifies by
|
||||
RELOAD/READBACK, not by successful in-memory mutation.
|
||||
|
||||
B. BLIND SOURCE-EDIT NO-OP (multiple sessions: python str.replace() on
|
||||
ruff-reformatted files silently matched nothing; the script reported
|
||||
success while changing nothing). Guard: an automated edit verifies its
|
||||
POSTCONDITION — expected text present / old text absent — and a
|
||||
zero-match replacement is an explicit, truthful no-op, never success.
|
||||
|
||||
Not a generic editor framework. Two small, deterministic postcondition
|
||||
checks at the boundaries where the defects actually occurred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["verify_persisted", "assert_persisted", "apply_source_edit", "EditNoOp", "EditPostconditionFailed"]
|
||||
|
||||
|
||||
class EditNoOp(Exception):
|
||||
"""The edit matched zero targets — an explicit, truthful no-op."""
|
||||
|
||||
|
||||
class EditPostconditionFailed(Exception):
|
||||
"""The edit claimed success but the expected postcondition is absent."""
|
||||
|
||||
|
||||
# ── Guard A: durable mutation verified by readback ──────────────────────────
|
||||
|
||||
|
||||
def verify_persisted(path: Path | str, selector, expect) -> bool:
|
||||
"""Reload the durable store from disk and check the expectation against
|
||||
the RELOADED state. `selector(reloaded_doc) -> value`; pass == value
|
||||
equals `expect` (or expect(value) is truthy when callable).
|
||||
|
||||
The reloaded document is parsed as JSON when the file is JSON-shaped;
|
||||
otherwise the raw text is passed to the selector (plain-text stores).
|
||||
|
||||
This is the Tranche 5 defect guard: an in-memory mutation that was
|
||||
never save()d fails here, because the disk state is what gets read.
|
||||
"""
|
||||
raw = Path(path).read_text(encoding="utf-8")
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError:
|
||||
doc = raw
|
||||
value = selector(doc)
|
||||
if callable(expect):
|
||||
return bool(expect(value))
|
||||
return value == expect
|
||||
|
||||
|
||||
def assert_persisted(path: Path | str, selector, expect, *, what: str = "durable state") -> None:
|
||||
"""verify_persisted that raises on mismatch — for use right after a
|
||||
save() at durable boundaries."""
|
||||
if not verify_persisted(path, selector, expect):
|
||||
raise AssertionError(
|
||||
f"persistence verification FAILED for {what}: reloaded state "
|
||||
f"does not match expectation — the mutation may not have been "
|
||||
f"saved (Tranche 5 defect class A)"
|
||||
)
|
||||
|
||||
|
||||
# ── Guard B: source edit with postcondition ─────────────────────────────────
|
||||
|
||||
|
||||
def apply_source_edit(
|
||||
text: str,
|
||||
old: str,
|
||||
new: str,
|
||||
*,
|
||||
expected_count: int | None = None,
|
||||
postcondition: str | None = None,
|
||||
allow_noop: bool = False,
|
||||
) -> str:
|
||||
"""Apply a source edit WITH postcondition verification (Guard B).
|
||||
|
||||
- zero matches: raises EditNoOp (truthful no-op) unless allow_noop=True
|
||||
- expected_count given: match count must equal it
|
||||
- postcondition given: the RESULT must contain it, else
|
||||
EditPostconditionFailed
|
||||
Returns the new text. Never claims success for a change that did not
|
||||
happen."""
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
if allow_noop:
|
||||
return text # truthful, explicit no-op — caller records it
|
||||
raise EditNoOp(
|
||||
f"edit matched 0 targets — nothing changed (Tranche 5 defect "
|
||||
f"class B: str.replace silently no-ops on reformatted files)"
|
||||
)
|
||||
if expected_count is not None and count != expected_count:
|
||||
raise EditPostconditionFailed(f"edit matched {count} targets, expected {expected_count}")
|
||||
result = text.replace(old, new)
|
||||
if postcondition and postcondition not in result:
|
||||
raise EditPostconditionFailed(
|
||||
"postcondition text absent from result — edit did not achieve its expected effect"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def apply_source_edit_file(
|
||||
path: Path | str, old: str, new: str, *, expected_count: int | None = None, postcondition: str | None = None
|
||||
) -> int:
|
||||
"""File-level convenience: read, apply_source_edit, write back, then
|
||||
VERIFY BY READBACK (both guards composed). Returns match count."""
|
||||
p = Path(path)
|
||||
text = p.read_text(encoding="utf-8")
|
||||
result = apply_source_edit(text, old, new, expected_count=expected_count, postcondition=postcondition)
|
||||
p.write_text(result, encoding="utf-8")
|
||||
# Guard A composed: the written file must contain the postcondition
|
||||
if postcondition:
|
||||
assert_persisted(p, lambda t: t, lambda t: postcondition in t, what=f"edit of {p.name}")
|
||||
return text.count(old)
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""deploy_promotion.py — monotonic promotion authority for Rune deploys.
|
||||
|
||||
SCAFFOLD contract DEPLOY-MONOTONIC-001. Root cause of the 2026-09-13
|
||||
deploy-order race (deploys 7355/7359): deploy_stage.py promoted
|
||||
unconditionally in job-finish order — no comparison against the currently
|
||||
deployed commit, no lock, no truthful outcome for a stale candidate.
|
||||
|
||||
This module owns the PROMOTION decision. The invariant:
|
||||
|
||||
current never regresses along the canonical release branch.
|
||||
|
||||
Ordering authority: GIT ANCESTRY on the canonical release branch
|
||||
(refs/heads/master). Rune's release flow is linear history — every deploy
|
||||
candidate is a master push (tranche commit or its auto-bump descendant).
|
||||
candidate == deployed commit -> NOOP_ALREADY_CURRENT (idempotent)
|
||||
candidate is DESCENDANT of it -> allowed (normal forward deploy)
|
||||
candidate is ANCESTOR of it -> STALE_SUPERSEDED (refuse; truthful)
|
||||
no ancestry relationship -> REFUSED_DIVERGENT (fail closed)
|
||||
deployed commit unknown/unreadable-> REFUSED_UNKNOWN_CURRENT (fail closed)
|
||||
|
||||
Mechanism (smallest that provides the invariant, per brief §4-5):
|
||||
1. promotion-time stale check — ancestry comparison against the commit
|
||||
current ACTUALLY points to at promotion time (not at job start);
|
||||
2. an flock'd critical section around the read-compare-write of
|
||||
current/last-good so two concurrent promotions cannot interleave
|
||||
between the ancestry check and the symlink swap.
|
||||
Workflow-level concurrency is NOT relied on (brief §5): the fence lives at
|
||||
the promotion operation itself.
|
||||
|
||||
Truthful outcomes (brief §8) — build/stage success != promotion success:
|
||||
PROMOTED candidate became current (verified below)
|
||||
NOOP_ALREADY_CURRENT same commit already deployed; nothing mutated
|
||||
STALE_SUPERSEDED a newer commit is already current; nothing mutated
|
||||
REFUSED_DIVERGENT ordering unprovable; fail closed
|
||||
REFUSED_UNKNOWN_CURRENT deployed commit unreadable; fail closed
|
||||
FAILED_* verification/staging failures (in deploy_stage)
|
||||
|
||||
A stale or refused outcome is a NON-ERROR terminal state for the release
|
||||
pipeline (the newer build simply won) but MUST NOT print "PROMOTED" and
|
||||
MUST NOT mutate current/last-good. deploy_stage's exit code and the
|
||||
workflow's post-deploy runtime verification enforce this: the workflow
|
||||
asserts current == candidate after a PROMOTED outcome, so a stale outcome
|
||||
makes the job report honestly rather than masquerade.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
|
||||
RELEASE_BRANCH = "refs/heads/master"
|
||||
|
||||
OUTCOME_PROMOTED = "PROMOTED"
|
||||
OUTCOME_NOOP = "NOOP_ALREADY_CURRENT"
|
||||
OUTCOME_STALE = "STALE_SUPERSEDED"
|
||||
OUTCOME_DIVERGENT = "REFUSED_DIVERGENT"
|
||||
OUTCOME_UNKNOWN_CURRENT = "REFUSED_UNKNOWN_CURRENT"
|
||||
|
||||
|
||||
# ── pure decision logic (unit-tested; no I/O) ──────────────────────────────
|
||||
|
||||
|
||||
def decide_promotion(candidate_sha: str, deployed_sha: str, ancestry: str) -> str:
|
||||
"""Pure promotion decision from a precomputed ancestry relationship.
|
||||
ancestry is one of: 'same' | 'descendant' | 'ancestor' | 'divergent' |
|
||||
'unknown'. Fail-closed: anything not provably allowed is refused."""
|
||||
if candidate_sha == deployed_sha or ancestry == "same":
|
||||
return OUTCOME_NOOP
|
||||
if ancestry == "descendant":
|
||||
return OUTCOME_PROMOTED
|
||||
if ancestry == "ancestor":
|
||||
return OUTCOME_STALE
|
||||
if ancestry == "divergent":
|
||||
return OUTCOME_DIVERGENT
|
||||
return OUTCOME_UNKNOWN_CURRENT
|
||||
|
||||
|
||||
def ancestry_of(candidate_sha: str, deployed_sha: str, repo: Path, fetch_on_divergent: bool = True) -> str:
|
||||
"""Classify candidate vs deployed using git merge-base --is-ancestor on
|
||||
the canonical release branch's repository. Returns 'unknown' on any git
|
||||
failure (fail-closed callers treat that as refusal).
|
||||
|
||||
Observed live 2026-09-13 (deploy 7449, SHA 535c2909): a DELAYED older
|
||||
candidate's checkout cannot contain commits newer than itself — its
|
||||
local object store lacks the deployed commit entirely, so ancestry
|
||||
classifies 'divergent' and fail-closed refuses. Correct refusal, wrong
|
||||
reason: the candidate IS an ancestor. When the local classification is
|
||||
divergent, fetch origin's canonical branch first (best-effort; the
|
||||
deploy workflow's checkout has credentials configured) and re-classify.
|
||||
If still divergent afterwards, the refusal stands with better
|
||||
evidence."""
|
||||
|
||||
def _is_ancestor(a: str, b: str) -> bool:
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "merge-base", "--is-ancestor", a, b],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return r.returncode == 0
|
||||
|
||||
if candidate_sha == deployed_sha:
|
||||
return "same"
|
||||
if _is_ancestor(candidate_sha, deployed_sha):
|
||||
return "ancestor" # candidate is OLDER than what's deployed
|
||||
if _is_ancestor(deployed_sha, candidate_sha):
|
||||
return "descendant" # candidate is NEWER (normal forward deploy)
|
||||
if fetch_on_divergent:
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "fetch", "origin", "master", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass # best-effort: classification below still fails closed
|
||||
if _is_ancestor(candidate_sha, deployed_sha):
|
||||
return "ancestor"
|
||||
if _is_ancestor(deployed_sha, candidate_sha):
|
||||
return "descendant"
|
||||
return "divergent"
|
||||
|
||||
|
||||
def read_deployed_commit(deploy_root: Path = DEPLOY_ROOT) -> str:
|
||||
"""Read the commit the `current` symlink ACTUALLY points to right now —
|
||||
from the deployed build identity, the deployment authority (never from
|
||||
a stale job-start artifact). Empty string if unreadable."""
|
||||
current = deploy_root / "current"
|
||||
try:
|
||||
target = Path(os.readlink(current))
|
||||
except OSError:
|
||||
return ""
|
||||
identity = target / "src" / "rune" / "_build_identity.json"
|
||||
try:
|
||||
data = json.loads(identity.read_text(encoding="utf-8"))
|
||||
return str(data.get("commit", ""))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
# ── the fenced promotion operation ──────────────────────────────────────────
|
||||
|
||||
|
||||
def promote(
|
||||
candidate_sha: str,
|
||||
stage: Path,
|
||||
tmp_stage: Path,
|
||||
repo: Path,
|
||||
deploy_root: Path = DEPLOY_ROOT,
|
||||
on_promoted=None,
|
||||
) -> tuple[str, str]:
|
||||
"""Atomically decide + promote under an exclusive lock.
|
||||
|
||||
Returns (outcome, detail). ONLY the PROMOTED outcome mutates
|
||||
current/last-good, and it does so inside the same critical section as
|
||||
the decision — no interleaving window between check and swap.
|
||||
|
||||
Crash-safety ordering (brief §16): the stage rename happens BEFORE the
|
||||
lock (a staged-but-unpromoted tree is inert), the symlink swap is a
|
||||
single rename(2) via a temporary link, so current is never absent and
|
||||
never half-updated. last-good is refreshed BEFORE the swap only from a
|
||||
value that was genuinely current a moment ago.
|
||||
"""
|
||||
deploy_root.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = deploy_root / ".promotion.lock"
|
||||
with open(lock_path, "w") as lock_fd:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
deployed = read_deployed_commit(deploy_root)
|
||||
if not deployed:
|
||||
return (OUTCOME_UNKNOWN_CURRENT, "deployed commit unreadable — fail closed")
|
||||
rel = ancestry_of(candidate_sha, deployed, repo)
|
||||
outcome = decide_promotion(candidate_sha, deployed, rel)
|
||||
if outcome != OUTCOME_PROMOTED:
|
||||
return (outcome, f"deployed={deployed[:12]} ancestry={rel}")
|
||||
|
||||
# --- mutate (still inside the lock) ---
|
||||
current = deploy_root / "current"
|
||||
last_good = deploy_root / "last-good"
|
||||
prev_target = os.readlink(current) if current.is_symlink() else ""
|
||||
if prev_target and (deploy_root / "last-good").is_symlink():
|
||||
(deploy_root / "last-good").unlink()
|
||||
if prev_target:
|
||||
_atomic_symlink(deploy_root, "last-good", prev_target)
|
||||
if stage.exists():
|
||||
# re-deploying an already-staged SHA: restage over it
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(stage)
|
||||
os.rename(tmp_stage, stage)
|
||||
_atomic_symlink_target(deploy_root, "current", stage)
|
||||
detail = f"current -> {stage.name}"
|
||||
if on_promoted is not None:
|
||||
# §12C: race-safe convergence — the WINNER refreshes release
|
||||
# history atomically with its promotion, inside the lock.
|
||||
# Failure degrades to STALE (software valid, history lags;
|
||||
# retry by any later deploy converges idempotently).
|
||||
try:
|
||||
on_promoted(candidate_sha)
|
||||
detail += " (+ release-history converged)"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
detail += f" (release-history refresh FAILED: {exc!s:.120} — STALE, retry converges)"
|
||||
return (OUTCOME_PROMOTED, detail)
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _atomic_symlink(deploy_root: Path, name: str, target: str) -> None:
|
||||
"""Symlink swap that never leaves `name` absent: write temp link, rename
|
||||
over the final name (rename(2) is atomic on the same filesystem)."""
|
||||
_atomic_symlink_target(deploy_root, name, Path(target))
|
||||
|
||||
|
||||
def _atomic_symlink_target(deploy_root: Path, name: str, target: Path) -> None:
|
||||
tmp = deploy_root / f".{name}.swap-{os.getpid()}"
|
||||
if tmp.is_symlink() or tmp.exists():
|
||||
tmp.unlink()
|
||||
tmp.symlink_to(target)
|
||||
os.replace(tmp, deploy_root / name)
|
||||
+115
-18
@@ -6,6 +6,16 @@ has no embedded multi-line scripts — a YAML block-scalar indentation hazard).
|
||||
|
||||
Prints one of: success | failure | pending
|
||||
Exit code 0 always (the shell decides what to do with the verdict).
|
||||
|
||||
RELEASE-SAFETY HARDENING (2026-09-13, after red-CI promotions 0.66.28/29):
|
||||
the verdict is the AND of two independent legs —
|
||||
1. required-checks context (newest observation for this exact SHA);
|
||||
2. the latest COMPLETED ci.yml@refs/heads/master run for this exact SHA,
|
||||
every job concluded success (branch+tag fan-out resolved by path;
|
||||
jobs envelope unwrapped — no aggregate-commit-status fallback, the
|
||||
exact bug class verify.py hit in W/R-v0.3).
|
||||
Any disagreement, pending, or missing evidence → pending/failure.
|
||||
FAIL-CLOSED: not verified → not deployable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,6 +30,102 @@ if _SCRIPT_DIR not in _sys.path:
|
||||
_sys.path.insert(0, _SCRIPT_DIR)
|
||||
import gitea_auth
|
||||
|
||||
REQUIRED_CONTEXT = "CI Pipeline / required-checks"
|
||||
CI_WORKFLOW_PATH = "ci.yml@refs/heads/master"
|
||||
|
||||
|
||||
# ── pure verdict logic (unit-tested; fixtures from the real incidents) ────
|
||||
|
||||
def required_check_verdict(statuses: list[dict]) -> str:
|
||||
"""Newest non-skipped required-checks observation for the SHA.
|
||||
Missing → pending. Newest wins (stale success never overrides a newer
|
||||
failure, and vice versa)."""
|
||||
candidates = [
|
||||
s for s in statuses
|
||||
if str(s.get("context", "")).startswith(REQUIRED_CONTEXT)
|
||||
and s.get("status") != "skipped"
|
||||
]
|
||||
if not candidates:
|
||||
return "pending"
|
||||
newest = max(candidates, key=lambda s: str(s.get("created_at", "")))
|
||||
return str(newest.get("status", "pending"))
|
||||
|
||||
|
||||
def independent_jobs_verdict(runs: list[dict], jobs_fetch, sha: str = "") -> str:
|
||||
"""Latest COMPLETED ci.yml@refs/heads/master run for the exact SHA:
|
||||
every job must conclude success. Fan-out-safe (tag runs and sibling
|
||||
workflows are excluded by path); the run is resolved by head_sha, never
|
||||
by 'latest branch state'. Missing run → pending. Any non-success
|
||||
conclusion → failure."""
|
||||
mine = [r for r in runs if r.get("path") == CI_WORKFLOW_PATH]
|
||||
if sha:
|
||||
mine = [r for r in mine if str(r.get("head_sha", "")) == sha]
|
||||
completed = [r for r in mine if r.get("status") == "completed"]
|
||||
if not completed:
|
||||
return "pending"
|
||||
latest = max(completed, key=lambda r: str(r.get("created_at", "") or r.get("run_started_at", "")))
|
||||
jobs = jobs_fetch(latest.get("id"))
|
||||
if jobs is None:
|
||||
return "pending" # jobs probe failed → not verified
|
||||
if not jobs:
|
||||
return "pending" # no jobs visible → cannot verify
|
||||
for j in jobs:
|
||||
if j.get("conclusion") != "success":
|
||||
return "failure"
|
||||
return "success"
|
||||
|
||||
|
||||
def combine(a: str, b: str) -> str:
|
||||
"""Fail-closed conjunction: any failure → failure; any pending → pending;
|
||||
success only when BOTH legs are success."""
|
||||
if "failure" in (a, b):
|
||||
return "failure"
|
||||
if "pending" in (a, b):
|
||||
return "pending"
|
||||
return "success"
|
||||
|
||||
|
||||
def qualify(statuses: list[dict], runs: list[dict], jobs_fetch, sha: str = "") -> str:
|
||||
return combine(required_check_verdict(statuses),
|
||||
independent_jobs_verdict(runs, jobs_fetch, sha=sha))
|
||||
|
||||
|
||||
# ── API fetches (thin; injectable in tests) ───────────────────────────────
|
||||
|
||||
def _get_json(url: str, token: str, timeout: int = 30):
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_status(server: str, repo: str, sha: str, token: str) -> list[dict] | None:
|
||||
data = _get_json(f"{server}/api/v1/repos/{repo}/commits/{sha}/status", token)
|
||||
return None if data is None else data.get("statuses", [])
|
||||
|
||||
|
||||
def _fetch_runs(server: str, repo: str, sha: str, token: str) -> list[dict] | None:
|
||||
data = _get_json(f"{server}/api/v1/repos/{repo}/actions/runs?limit=50", token)
|
||||
if data is None:
|
||||
return None
|
||||
runs = data.get("workflow_runs", data if isinstance(data, list) else [])
|
||||
return [r for r in runs if str(r.get("head_sha", "")) == sha]
|
||||
|
||||
|
||||
def _fetch_jobs(server: str, repo: str, run_id, token: str) -> list[dict] | None:
|
||||
data = _get_json(f"{server}/api/v1/repos/{repo}/actions/runs/{run_id}/jobs", token)
|
||||
if data is None:
|
||||
return None
|
||||
# jobs envelope unwrap (this Gitea wraps in {"jobs": [...]}; some
|
||||
# versions return a bare list) — never fall back to aggregate status
|
||||
if isinstance(data, dict):
|
||||
return data.get("jobs", [])
|
||||
return data if isinstance(data, list) else None
|
||||
|
||||
|
||||
# ── CLI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
server = os.environ.get("GITEA_SERVER", "").rstrip("/")
|
||||
@@ -29,25 +135,16 @@ def main() -> int:
|
||||
if not (server and repo and sha and token):
|
||||
print("pending") # missing config → cannot verify → not qualified
|
||||
return 0
|
||||
url = f"{server}/api/v1/repos/{repo}/commits/{sha}/status"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = json.loads(r.read())
|
||||
except (OSError, ValueError) as exc:
|
||||
# transient API/network failure → keep polling (urllib raises
|
||||
# URLError (OSError subclass); bad JSON raises ValueError)
|
||||
print(f"pending (status probe failed: {exc})")
|
||||
|
||||
statuses = _fetch_status(server, repo, sha, token)
|
||||
runs = _fetch_runs(server, repo, sha, token)
|
||||
if statuses is None or runs is None:
|
||||
# transient API/network failure → keep polling
|
||||
print("pending (probe failed)")
|
||||
return 0
|
||||
required = [
|
||||
s
|
||||
for s in data.get("statuses", [])
|
||||
if (str(s.get("context", "")).startswith("CI Pipeline / required-checks") and s.get("status") != "skipped")
|
||||
]
|
||||
if not required:
|
||||
print("pending")
|
||||
else:
|
||||
print(required[0].get("status", "pending"))
|
||||
jobs_fetch = lambda run_id: _fetch_jobs(server, repo, run_id, token) # noqa: E731
|
||||
verdict = qualify(statuses, runs, jobs_fetch, sha=sha)
|
||||
print(verdict)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+137
-24
@@ -3,12 +3,15 @@
|
||||
|
||||
Called by .gitea/workflows/deploy.yml. Stages src/ into
|
||||
/mnt/workdrive/rune-code/<short-sha>, stamps _build_identity.json, verifies
|
||||
the staged tree (import + SHA assert + authority smoke), then ATOMICALLY
|
||||
promotes the `current` symlink and records `last-good` (safe rollback:
|
||||
the previous deployment remains available).
|
||||
the staged tree (import + SHA assert + authority smoke), then promotes
|
||||
through the MONOTONIC promotion authority (deploy_promotion.py): an older
|
||||
candidate finishing after a newer promoted one is fenced out (STALE_SUPERSEDED)
|
||||
and reports truthfully — build/stage success != promotion success.
|
||||
|
||||
Exit 0 = deployed and verified. Non-zero = deployment refused; the previous
|
||||
deployment stays current.
|
||||
Exit 0 = PROMOTED and verified, or NOOP_ALREADY_CURRENT, or a truthful
|
||||
STALE_SUPERSEDED (newer build already current; nothing mutated).
|
||||
Non-zero = deployment refused (divergent/unknown ordering, verification
|
||||
failure); the previous deployment stays current.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +28,92 @@ DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
VENV_PY = "/home/nick/.venvs/memory/bin/python3"
|
||||
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||
import deploy_promotion # noqa: E402
|
||||
from rune.release_notes import ( # noqa: E402
|
||||
ReleaseNote as _ReleaseNote,
|
||||
auto_initial_note as _auto_note,
|
||||
converged_deployed_sha as _authority_sha,
|
||||
convergence_refresh as _refresh,
|
||||
fetch_release as _fetch_release,
|
||||
publish_release as _publish,
|
||||
sha_from_body as _sha_from_body,
|
||||
summary_from_body as _summary_from_body,
|
||||
)
|
||||
|
||||
|
||||
def publish_out_of_lock(short: str, sha: str, *,
|
||||
deploy_root: Path = DEPLOY_ROOT,
|
||||
scaffold_store: Path | None = None) -> None:
|
||||
"""§13D/§14B: release-history publication AFTER the promotion lock is
|
||||
released. Network I/O never holds the flock.
|
||||
|
||||
§14B INVARIANT: publication does NOT trust the originating job's SHA.
|
||||
The CURRENT deployment authority is re-read immediately before
|
||||
publishing; the note is constructed from CURRENT authority. A stale
|
||||
job whose candidate was superseded converges the NEW authority (or
|
||||
refuses a version it cannot truthfully describe) — it may help
|
||||
converge B, but may never reassert itself as deployed.
|
||||
Failure degrades to STALE (software valid, history lags; retry
|
||||
converges)."""
|
||||
try:
|
||||
server = os.environ.get("GITEA_SERVER", "").rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO", "")
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
if not (server and repo and token):
|
||||
return
|
||||
# §14B: re-read CURRENT authority — never trust the job's SHA.
|
||||
authority_sha = _authority_sha(str(deploy_root))
|
||||
if not authority_sha:
|
||||
print("WARNING: release-history convergence skipped: "
|
||||
"deployment authority unreadable — STALE, retry converges")
|
||||
return
|
||||
store = str(scaffold_store or (REPO_ROOT / "store" / "scaffold" / "current.json"))
|
||||
identity = deploy_root / authority_sha[:12] / "src" / "rune" / "_build_identity.json"
|
||||
try:
|
||||
version = json.loads(identity.read_text()).get("version", "")
|
||||
except (OSError, ValueError):
|
||||
version = ""
|
||||
if not version:
|
||||
print("WARNING: release-history convergence skipped: "
|
||||
"no version on current authority — STALE, retry converges")
|
||||
return
|
||||
tag = f"v{version}"
|
||||
existing = _fetch_release(server, repo, token, tag)
|
||||
if existing is None:
|
||||
note = _auto_note(
|
||||
version=version,
|
||||
summary=(f"Rune v{version} — evidence-derived release "
|
||||
f"history (automatic convergence)"),
|
||||
scaffold_store=store,
|
||||
release_sha=authority_sha, deployed_sha=authority_sha,
|
||||
provenance=["automatic convergence publish "
|
||||
"(deploy workflow, authority-derived)"])
|
||||
else:
|
||||
note = _refresh(
|
||||
_ReleaseNote.from_dict({
|
||||
"version": version,
|
||||
"summary": _summary_from_body(existing.get("body", "")),
|
||||
"release_sha": _sha_from_body(existing.get("body", ""), "tag")
|
||||
or authority_sha,
|
||||
"provenance": [],
|
||||
}),
|
||||
deployed_sha=authority_sha, scaffold_store=store,
|
||||
extra_provenance="automatic convergence publish "
|
||||
"(deploy workflow, authority-derived)")
|
||||
# preserve the tag SHA the stored body carries
|
||||
tag_sha = _sha_from_body(existing.get("body", ""), "tag")
|
||||
if tag_sha:
|
||||
note.release_sha = tag_sha
|
||||
result = _publish(server, repo, token, note)
|
||||
print(f"release-history convergence: "
|
||||
f"{result.get('action', 'unknown')} "
|
||||
f"(authority {authority_sha[:12]})")
|
||||
except Exception as conv_exc: # noqa: BLE001 — history STALE, software valid
|
||||
print(f"WARNING: release-history convergence failed: "
|
||||
f"{conv_exc!s:.200} — STALE, retry converges")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# The runner's checkout workspace (act sets GITHUB_WORKSPACE). Never a
|
||||
@@ -119,25 +208,49 @@ print("VERIFIED:", json.dumps(info))
|
||||
shutil.rmtree(tmp_stage, ignore_errors=True)
|
||||
return 1
|
||||
|
||||
# --- promote (atomic) + last-known-good ------------------------------
|
||||
DEPLOY_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
current = DEPLOY_ROOT / "current"
|
||||
if current.is_symlink():
|
||||
last_good = DEPLOY_ROOT / "last-good"
|
||||
if last_good.is_symlink():
|
||||
last_good.unlink()
|
||||
last_good.symlink_to(os.readlink(current))
|
||||
if current.is_symlink() or current.exists():
|
||||
current.unlink()
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage) # re-deploying an already-staged SHA: restage
|
||||
os.rename(tmp_stage, stage)
|
||||
current.symlink_to(stage)
|
||||
print(f"PROMOTED: {short}")
|
||||
print("current ->", os.readlink(current))
|
||||
if (DEPLOY_ROOT / "last-good").is_symlink():
|
||||
print("last-good ->", os.readlink(DEPLOY_ROOT / "last-good"))
|
||||
return 0
|
||||
# --- promote: FENCED through the promotion authority -----------------
|
||||
# SCAFFOLD contract DEPLOY-MONOTONIC-001: promotion is monotonic. The
|
||||
# ancestry check + lock live in deploy_promotion.py; this script only
|
||||
# handles the truthful outcome reporting. A stale candidate must NOT
|
||||
# claim promotion success — build/stage success != promotion success.
|
||||
#
|
||||
# §14C: NO separate convergence-intent ledger. The durable pending-
|
||||
# convergence state IS the deployment authority itself (atomically
|
||||
# swapped `current` symlink + stamped build identity): publication
|
||||
# derives from it, idempotently, outside the lock. The old intent
|
||||
# file had exactly one writer and zero readers, non-atomic writes,
|
||||
# and a bogus recorded_at (SECONDS is a bash builtin, never exported
|
||||
# — always "0"). Deriving from authority gives atomicity, restart
|
||||
# readability, corruption immunity, monotonicity (stale publishers
|
||||
# read CURRENT authority, not their own job) and idempotent retry
|
||||
# for free. REMOVE more complexity than you add.
|
||||
|
||||
outcome, detail = deploy_promotion.promote(
|
||||
sha, DEPLOY_ROOT / short, tmp_stage, repo)
|
||||
|
||||
# §13D: publish release history OUTSIDE the promotion lock (network
|
||||
# I/O must not hold the flock). Only for PROMOTED outcomes.
|
||||
if outcome == deploy_promotion.OUTCOME_PROMOTED:
|
||||
publish_out_of_lock(short, sha)
|
||||
|
||||
if outcome == deploy_promotion.OUTCOME_PROMOTED:
|
||||
print(f"PROMOTED: {short}")
|
||||
print("current ->", os.readlink(DEPLOY_ROOT / "current"))
|
||||
if (DEPLOY_ROOT / "last-good").is_symlink():
|
||||
print("last-good ->", os.readlink(DEPLOY_ROOT / "last-good"))
|
||||
return 0
|
||||
if outcome == deploy_promotion.OUTCOME_NOOP:
|
||||
print(f"NOOP_ALREADY_CURRENT: {short} is the deployed commit")
|
||||
print(detail)
|
||||
return 0
|
||||
if outcome == deploy_promotion.OUTCOME_STALE:
|
||||
print(f"STALE_SUPERSEDED: {short} is older than the deployed commit — promotion refused (monotonic promotion)")
|
||||
print(detail)
|
||||
print("current unchanged:", os.readlink(DEPLOY_ROOT / "current"))
|
||||
return 0 # truthful non-error terminal state: the newer build won
|
||||
# divergent / unknown-current: fail closed
|
||||
print(f"REFUSED ({outcome}): {short} — {detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""notify_event_bridge.py — T16 §16C: ci.yml's final job posts the CI
|
||||
completion WAKE to the local W/R event bridge (daemon /process-event).
|
||||
|
||||
The event is a WAKE, never completion truth: the waiting controller
|
||||
(ci_event_wait.py) re-reads the canonical Gitea verdict via
|
||||
deploy_qualify.py after resuming. Best-effort by design — if the bridge
|
||||
is unreachable the waiter's watchdog still recovers truthfully (that is
|
||||
the §16D lost-event contract), so a notify failure must NEVER fail CI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
BRIDGE_URL = os.environ.get("RUNE_EVENT_BRIDGE_URL",
|
||||
"http://127.0.0.1:8791")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sha = os.environ.get("CI_SHA", "")
|
||||
result = os.environ.get("CI_RESULT", "") # needs.<job>.result
|
||||
if not sha:
|
||||
print("notify-event-bridge: no CI_SHA — nothing to wake", flush=True)
|
||||
return 0
|
||||
payload = {
|
||||
"operation_id": f"ci-{sha}",
|
||||
"provider": "gitea",
|
||||
"event_type": "ci_completed",
|
||||
"sha": sha,
|
||||
# exit_code is informational only; the wake is not the verdict
|
||||
"exit_code": 0 if result == "success" else 1,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
for attempt in range(2):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
BRIDGE_URL + "/process-event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
resp = json.loads(r.read())
|
||||
print(f"notify-event-bridge: woke bridge "
|
||||
f"(matched={resp.get('matched')})", flush=True)
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001 — wake is best-effort by design; failure is logged and the waiter's watchdog recovery covers a lost wake (§16D contract)
|
||||
if attempt == 0:
|
||||
time.sleep(2)
|
||||
else:
|
||||
print(f"notify-event-bridge: bridge unreachable ({exc}); "
|
||||
f"watchdog recovery covers a lost wake", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""release_history_cli.py — thin workflow entry points for release history.
|
||||
|
||||
SCAFFOLD-TRANCHE-009B §4: the workflows invoke THIS tested module; no
|
||||
note-building logic lives in YAML. Two moments:
|
||||
|
||||
init RELEASE INITIALIZATION — auto-version creates the evidence-
|
||||
derived Gitea release when the version/tag appears.
|
||||
refresh RELEASE CONVERGENCE REFRESH — deploy promotes a later SHA of
|
||||
the same version; the SAME release is updated with the final
|
||||
deployed identity + newly-governed facts. Idempotent.
|
||||
|
||||
Fail-closed boundary (§5/§16): `init` exits non-zero on publication
|
||||
failure — Rune cannot silently create another empty historical release.
|
||||
`refresh` ALSO exits non-zero (visible failure + retry converges), but the
|
||||
workflow marks it non-blocking for deployment validity: valid software is
|
||||
never rolled back over documentation. Failure state is visible either way.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
from rune import release_notes as rn # noqa: E402
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
tok = os.environ.get("GITEA_TOKEN", "")
|
||||
if tok:
|
||||
return tok
|
||||
# local/dev fallback: git credential store (runner uses GITEA_TOKEN)
|
||||
cred = Path.home() / ".git-credentials"
|
||||
if cred.exists():
|
||||
import re
|
||||
m = re.search(r"https://[^:]+:([^@]+)@", cred.read_text())
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_init = sub.add_parser("init", help="release initialization")
|
||||
p_init.add_argument("--version", required=True)
|
||||
p_init.add_argument("--server", required=True)
|
||||
p_init.add_argument("--repo", required=True)
|
||||
p_init.add_argument("--store", default="store/scaffold/current.json")
|
||||
p_init.add_argument("--release-sha", default="")
|
||||
p_init.add_argument("--deployed-sha", default="")
|
||||
|
||||
p_ref = sub.add_parser("refresh", help="convergence refresh")
|
||||
p_ref.add_argument("--version", required=True)
|
||||
p_ref.add_argument("--server", required=True)
|
||||
p_ref.add_argument("--repo", required=True)
|
||||
p_ref.add_argument("--store", default="store/scaffold/current.json")
|
||||
p_ref.add_argument("--release-sha", default="")
|
||||
p_ref.add_argument("--deployed-sha", required=True,
|
||||
help="converged deployed commit (from the deployment "
|
||||
"authority — required, never assumed)")
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
token = _token()
|
||||
if not token:
|
||||
print(json.dumps({"failed": True,
|
||||
"error": "no Gitea token available"}))
|
||||
return 1
|
||||
|
||||
if args.cmd == "init":
|
||||
note = rn.auto_initial_note(
|
||||
version=args.version,
|
||||
summary=f"Rune v{args.version} — evidence-derived release "
|
||||
f"history (automatic initialization)",
|
||||
scaffold_store=args.store,
|
||||
release_sha=args.release_sha,
|
||||
deployed_sha=args.deployed_sha or args.release_sha,
|
||||
)
|
||||
result = rn.publish_release(args.server, args.repo, token, note)
|
||||
print(json.dumps(result))
|
||||
# fail-closed: initialization failure must be visible so Rune cannot
|
||||
# silently create another empty historical release (§5/§16)
|
||||
return 0 if not result.get("failed") else 1
|
||||
|
||||
if args.cmd == "refresh":
|
||||
# §12C RACE-SAFE REVALIDATION: between promotion and refresh, a
|
||||
# NEWER deploy may have superseded this candidate. The deployment
|
||||
# authority is re-read NOW: if current's build identity differs
|
||||
# from the candidate, this job is stale → refuse (preserve T11
|
||||
# invariant: stale jobs never overwrite release history). When
|
||||
# current AGREES, the candidate IS the converged truth.
|
||||
current_link = os.environ.get(
|
||||
"RUNE_DEPLOY_CURRENT", "/mnt/workdrive/rune-code/current")
|
||||
try:
|
||||
deployed_now = rn.converged_deployed_sha(
|
||||
str(Path(current_link).parent))
|
||||
except (OSError, ValueError):
|
||||
deployed_now = ""
|
||||
if args.deployed_sha and deployed_now and \
|
||||
not deployed_now.startswith(args.deployed_sha[:12]):
|
||||
print(json.dumps({
|
||||
"failed": True, "action": "refresh-superseded",
|
||||
"error": f"deployment authority shows {deployed_now[:12]}, "
|
||||
f"candidate {args.deployed_sha[:12]} was superseded "
|
||||
f"— stale refresh refused (history preserved)",
|
||||
"authority": deployed_now[:12],
|
||||
}))
|
||||
return 1 # visible; retry by the WINNING deploy converges
|
||||
|
||||
existing = rn.fetch_release(args.server, args.repo, token,
|
||||
f"v{args.version}")
|
||||
if existing is None:
|
||||
# no release yet for this version — init instead (convergence
|
||||
# may legitimately arrive before/without an init if the
|
||||
# auto-version run failed); idempotent recovery path
|
||||
note = rn.build_from_deployment(
|
||||
version=args.version, deployed_sha=args.deployed_sha,
|
||||
scaffold_store=args.store, release_sha=args.release_sha)
|
||||
else:
|
||||
note = rn.convergence_refresh(
|
||||
rn.ReleaseNote.from_dict({
|
||||
"version": args.version,
|
||||
"summary": _summary_from_body(existing.get("body", "")),
|
||||
"provenance": [],
|
||||
}),
|
||||
deployed_sha=args.deployed_sha, scaffold_store=args.store,
|
||||
extra_provenance="automatic convergence refresh (deploy "
|
||||
"workflow)")
|
||||
# preserve structural fields the stored body carries
|
||||
note.release_sha = _sha_from_body(existing.get("body", ""),
|
||||
"tag") or args.release_sha
|
||||
result = rn.publish_release(args.server, args.repo, token, note)
|
||||
print(json.dumps(result))
|
||||
# visible failure; retry converges (§10). Deployment validity is
|
||||
# NOT rolled back over documentation — the workflow marks this
|
||||
# step appropriately.
|
||||
return 0 if not result.get("failed") else 1
|
||||
return 2
|
||||
|
||||
|
||||
def _summary_from_body(body: str) -> str:
|
||||
"""Delegate to the single authority in rune.release_notes (§14B)."""
|
||||
return rn.summary_from_body(body)
|
||||
|
||||
|
||||
def _sha_from_body(body: str, which: str) -> str:
|
||||
return rn.sha_from_body(body, which)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -181,7 +181,8 @@ def run_stage_with_relay(tree, out_dir: str) -> dict:
|
||||
err_path = os.path.join(out_dir, f"stage_{stage}.mimo.err.log")
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
|
||||
cmd = [
|
||||
|
||||
@@ -77,9 +77,17 @@ def _extract_keywords(text: str) -> list[str]:
|
||||
|
||||
|
||||
def _load_manifest() -> dict:
|
||||
"""Structural facts from SPINE (the single structural authority).
|
||||
|
||||
Phase D: reads the Spine-generated compatibility view (same schema as
|
||||
the old manual manifest). The view is OUTPUT — the authority is
|
||||
store/spine/current.json; direct edits to the view are flagged by
|
||||
`spine drift`. If a manual manifest is present (Spine not yet seeded
|
||||
in this checkout), it degrades gracefully to that legacy truth."""
|
||||
if os.path.exists(UMBRELLA_MANIFEST):
|
||||
with open(UMBRELLA_MANIFEST) as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
return data # generated view or manual manifest — both work
|
||||
return {"umbrellas": {}}
|
||||
|
||||
|
||||
|
||||
@@ -793,7 +793,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_OUTWARD.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_OUTWARD.mimo.err.log")
|
||||
@@ -933,7 +934,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_APPLICABILITY.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_APPLICABILITY.mimo.err.log")
|
||||
@@ -1137,7 +1139,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_ADVERSARIAL.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_ADVERSARIAL.mimo.err.log")
|
||||
@@ -1345,7 +1348,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp).replace("{n}", str(self.issue))
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_INNOVATION.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_INNOVATION.mimo.err.log")
|
||||
@@ -1534,7 +1538,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_SYNTHESIS.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_SYNTHESIS.mimo.err.log")
|
||||
@@ -1671,7 +1676,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp).replace("{n}", str(self.issue))
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_QUALIFY.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_QUALIFY.mimo.err.log")
|
||||
|
||||
Executable
+205
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""scaffold_projection.py — Scaffolding V1 Tranche 1 (projection, not authority).
|
||||
|
||||
Answers, per component/module:
|
||||
what subsystem owns X? → SPINE store (structural authority)
|
||||
what does X depend on? → SPINE relationships (ingested from AST)
|
||||
who consumes X? → SPINE relationships (ingested from AST)
|
||||
what changes if X changes? → mirrors + dependent subsystems + consumers (SPINE)
|
||||
what proof obligations arise? → unit test, canary, ownership, umbrella gates
|
||||
which obligations are complete? → evidence check (test file exists, canary registered)
|
||||
|
||||
SPINE INTEGRATION: structural facts come from the Spine store — the single
|
||||
structural authority (ingested once from the umbrella manifest + AST).
|
||||
This script adds change-impact and proof-obligation reasoning on top; it
|
||||
no longer re-derives structure. No store → builds one (deterministic).
|
||||
|
||||
Commands:
|
||||
component <module> full card for one src/rune module
|
||||
impact <module> expected impact + obligations BEFORE a change
|
||||
obligations <module> obligation checklist with satisfied/pending state
|
||||
all [outfile] write the full projection JSON (default docs/)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import ast, json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / "src" / "rune"
|
||||
TESTS = ROOT / "tests"
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
from rune import spine # noqa: E402 — Spine IS the structural authority
|
||||
|
||||
|
||||
def load_spine() -> dict:
|
||||
"""Structural state from the Spine store (built on first use)."""
|
||||
doc = spine.load()
|
||||
if doc is None:
|
||||
doc = spine.build()
|
||||
spine.save(doc)
|
||||
return doc
|
||||
|
||||
|
||||
def module_imports(module: str) -> set[str]:
|
||||
"""Module names imported by src/rune/<module>.py (intra-package).
|
||||
|
||||
Includes relative imports (`from .x import y` — the dual-mode lazy
|
||||
pattern used across the runtime; missing these would hide real
|
||||
consumers, observed first run: rune_operator ↔ local_tool_runtime)."""
|
||||
path = SRC / f"{module}.py"
|
||||
if not path.is_file():
|
||||
return set()
|
||||
tree = ast.parse(path.read_text())
|
||||
names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
names.add(a.name.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
names.add(node.module.split(".")[0])
|
||||
return names
|
||||
|
||||
|
||||
def import_graph() -> dict[str, set[str]]:
|
||||
return {p.stem: module_imports(p.stem) for p in SRC.glob("*.py")}
|
||||
|
||||
|
||||
def consumers_of(module: str, graph: dict[str, set[str]]) -> set[str]:
|
||||
return {m for m, deps in graph.items() if module in deps and m != module}
|
||||
|
||||
|
||||
def subsystem_for(doc: dict, module: str) -> str:
|
||||
rels = spine.relationships(doc, from_id=f"module:{module}", type="belongs_to")
|
||||
return rels[0]["to"].split(":", 1)[1] if rels else ""
|
||||
|
||||
|
||||
def dependent_subsystems(doc: dict, subsystem: str) -> list[str]:
|
||||
return [r["from"].split(":", 1)[1] for r in
|
||||
spine.relationships(doc, to_id=f"subsystem:{subsystem}", type="depends_on")]
|
||||
|
||||
|
||||
def mirrors_for(doc: dict, module: str) -> list[str]:
|
||||
subs = spine.relationships(doc, from_id=f"module:{module}", type="belongs_to")
|
||||
out = []
|
||||
for s in subs:
|
||||
out.extend(r["to"].split(":", 1)[1] for r in
|
||||
spine.relationships(doc, from_id=s["to"], type="mirrors"))
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def canary_registered(module: str) -> bool:
|
||||
try:
|
||||
sys.path.insert(0, str(SRC))
|
||||
from rune import canary_native
|
||||
return module in canary_native.MODULES
|
||||
except ImportError:
|
||||
# canary_native pulls optional deps (sqlite_vec etc.); the projection
|
||||
# degrades to "not registered" rather than failing the audit.
|
||||
return False
|
||||
|
||||
|
||||
def obligations(module: str, doc: dict) -> list[dict]:
|
||||
"""Proof obligations for changing this module, with evidence state.
|
||||
Structural facts sourced from Spine."""
|
||||
umb = subsystem_for(doc, module)
|
||||
test_file = TESTS / f"test_{module}.py"
|
||||
obs = [
|
||||
{"obligation": "unit_test", "satisfied": test_file.is_file(),
|
||||
"evidence": f"tests/test_{module}.py" + ("" if test_file.is_file() else " (MISSING)")},
|
||||
{"obligation": "canary", "satisfied": canary_registered(module),
|
||||
"evidence": "canary_native.MODULES" if canary_registered(module)
|
||||
else "not in canary registry (informational — not every module needs one)"},
|
||||
{"obligation": "umbrella_manifest", "satisfied": bool(umb),
|
||||
"evidence": f"umbrella '{umb}'" if umb else "NOT in any umbrella — manifest gap"},
|
||||
{"obligation": "full_suite", "satisfied": None,
|
||||
"evidence": "run: pytest tests/ -q (runtime evidence, not static)"},
|
||||
{"obligation": "lint_ratchet", "satisfied": None,
|
||||
"evidence": "run: scripts/lint_ratchet.py"},
|
||||
{"obligation": "henxels", "satisfied": None,
|
||||
"evidence": "run: henxels check --all"},
|
||||
]
|
||||
if umb:
|
||||
deps = dependent_subsystems(doc, umb)
|
||||
if deps:
|
||||
obs.append({"obligation": "dependent_canaries", "satisfied": None,
|
||||
"evidence": f"subsystems depending on '{umb}': {', '.join(deps)} — their canaries gate the change"})
|
||||
mir = mirrors_for(doc, module)
|
||||
if mir:
|
||||
obs.append({"obligation": "mirrors_travel", "satisfied": None,
|
||||
"evidence": f"files that must travel together: {', '.join(mir)}"})
|
||||
return obs
|
||||
|
||||
|
||||
def component_card(module: str, doc: dict) -> dict:
|
||||
"""Structural card from SPINE (owner/deps/consumers/mirrors) + obligations."""
|
||||
deps = [r["to"].split(":", 1)[1] for r in
|
||||
spine.relationships(doc, from_id=f"module:{module}", type="depends_on")]
|
||||
consumers = [r["to"].split(":", 1)[1] for r in
|
||||
spine.relationships(doc, from_id=f"module:{module}", type="consumed_by")]
|
||||
e = spine.entity(doc, f"module:{module}")
|
||||
card = {
|
||||
"component": module,
|
||||
"subsystem": subsystem_for(doc, module),
|
||||
"dependencies": sorted(set(deps)),
|
||||
"consumers": sorted(set(consumers)),
|
||||
"mirrors": mirrors_for(doc, module),
|
||||
"obligations": obligations(module, doc),
|
||||
}
|
||||
if e:
|
||||
card["spine_maturity"] = e.get("maturity")
|
||||
card["spine_evidence"] = e.get("evidence_refs", [])
|
||||
return card
|
||||
|
||||
|
||||
def impact(module: str) -> dict:
|
||||
doc = load_spine()
|
||||
card = component_card(module, doc)
|
||||
umb = card["subsystem"]
|
||||
card["expected_impact"] = {
|
||||
"direct_consumers": card["consumers"],
|
||||
"subsystem_dependents": dependent_subsystems(doc, umb) if umb else [],
|
||||
"must_travel": card["mirrors"],
|
||||
"gates": ["full_suite", "lint_ratchet", "henxels", "umbrella_gates"]
|
||||
+ (["canary"] if card and any(o["obligation"] == "canary" and o["satisfied"]
|
||||
for o in card["obligations"]) else []),
|
||||
}
|
||||
return card
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return 2
|
||||
cmd = args[0]
|
||||
if cmd in ("component", "impact", "obligations") and len(args) < 2:
|
||||
print(f"usage: {cmd} <module>", file=sys.stderr)
|
||||
return 2
|
||||
doc = load_spine()
|
||||
if cmd == "impact":
|
||||
print(json.dumps(impact(args[1]), indent=1))
|
||||
elif cmd == "component":
|
||||
print(json.dumps(component_card(args[1], doc), indent=1))
|
||||
elif cmd == "obligations":
|
||||
card = component_card(args[1], doc)
|
||||
print(json.dumps(card["obligations"], indent=1))
|
||||
elif cmd == "all":
|
||||
out = Path(args[1]) if len(args) > 1 else ROOT / "docs" / "scaffold_projection.json"
|
||||
cards = {p.stem: component_card(p.stem, doc)
|
||||
for p in sorted(SRC.glob("*.py"))
|
||||
if spine.entity(doc, f"module:{p.stem}")}
|
||||
out.write_text(json.dumps({
|
||||
"generated_by": "scripts/scaffold_projection.py (projection — no new authority)",
|
||||
"sources": ["rune.spine store (structural authority)", "src/rune/canary_native.py",
|
||||
"tests/ filesystem"],
|
||||
"components": cards,
|
||||
}, indent=1) + "\n")
|
||||
print(f"projection written: {out} ({len(cards)} components)")
|
||||
else:
|
||||
print(f"unknown command: {cmd}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""t14_builder_session.py — T14D/14E REAL builder session driver.
|
||||
|
||||
The builder is the ACTUAL mimo CLI process (mimo run, model
|
||||
Xioami/mimo-v2.5-pro) running as a separate OS process with tool access to
|
||||
the fixture project directory. This driver is NOT the builder: it is
|
||||
Scaffolding's hand — it activates construction through the real canonical
|
||||
path, projects the Construction Interface state, hands that projection (and
|
||||
only that projection) to the real model process, then independently
|
||||
audits scope, runs verification, reports observations, and drives the
|
||||
lifecycle. Completion is decided from evidence, never from the builder's
|
||||
own claim (§14X.8).
|
||||
|
||||
Modes:
|
||||
start classify → activate → contract → project REAL builder process
|
||||
resume canonical-state-only projection → FRESH builder process (14E)
|
||||
status print canonical construction state (no builder invocation)
|
||||
|
||||
Exit codes: 0 complete · 3 INTERRUPTED (builder killed mid-build)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import construction_mode as cm # noqa: E402
|
||||
from rune import construction_activation as ca # noqa: E402
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
|
||||
USER_REQUEST = (
|
||||
"Build me a small standalone practice scheduler that can create, "
|
||||
"list and complete practice items."
|
||||
)
|
||||
EXPECTED_SCOPE = ["scheduler.js", "scheduler.test.js", "package.json"]
|
||||
# T16 §16F: NO silent default provider. The surrounding provider
|
||||
# layer sets T14_BUILDER_MODEL (falling back to the ambient MIMO_MODEL
|
||||
# context); construction never names a provider.
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent / "src"))
|
||||
from rune.provider_context import ProviderUnresolved, require_model # noqa: E402
|
||||
try:
|
||||
BUILDER_MODEL = os.environ.get("T14_BUILDER_MODEL", "").strip() \
|
||||
or require_model() # ambient provider context (MIMO_MODEL)
|
||||
except ProviderUnresolved as _exc:
|
||||
_sys.stderr.write(str(_exc) + "\n")
|
||||
raise SystemExit(3) # 3 = PROVIDER_UNRESOLVED (no silent fallback)
|
||||
BUILDER_CMD = "mimo"
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[t14] {msg}", flush=True)
|
||||
|
||||
|
||||
def classify_and_activate(store: Path, objective: str) -> dict:
|
||||
"""The REAL canonical path: classify → activate. No shortcuts."""
|
||||
parent = sc.Scaffold(str(store), "rune")
|
||||
parent.init(purpose="t14 fixture parent authority")
|
||||
decision = cm.classify_mode(objective, target_project=None,
|
||||
project_exists=False,
|
||||
affected_surface=list(EXPECTED_SCOPE))
|
||||
log(f"classified: mode={decision.mode} "
|
||||
f"target={decision.target_project!r}")
|
||||
need = cd.Need(
|
||||
need_id=f"need-{ss.now()}-t14", objective=objective,
|
||||
origin="rune_native", project=decision.target_project or "",
|
||||
constraints={}, affected_surface=list(EXPECTED_SCOPE),
|
||||
evidence_refs=[], approval_context="t14 user-approved fixture")
|
||||
rec = ca.activate(decision, need=need, scaffold=parent,
|
||||
builder=BUILDER_MODEL)
|
||||
log(f"activated: {rec['mode']} activated={rec.get('activated')} "
|
||||
f"project={rec.get('project')}")
|
||||
return {"decision": {"mode": decision.mode,
|
||||
"target_project": decision.target_project,
|
||||
"evidence": list(decision.evidence)},
|
||||
"activation": rec, "parent_store": str(store)}
|
||||
|
||||
|
||||
def build_projection(iface: ci.ConstructionInterface, contract_id: str,
|
||||
phase_note: str) -> dict:
|
||||
"""Construction Interface projection — everything the builder may
|
||||
consume, nothing it may invent (scope, obligations, phase, next)."""
|
||||
scope = iface.get_scope(contract_id)
|
||||
obligations = iface.get_obligations(contract_id)
|
||||
context = iface.construction_context(contract_id)
|
||||
assert scope.get("success") and obligations.get("success"), \
|
||||
f"projection failure: {scope.get('errors')} {obligations.get('errors')}"
|
||||
return {
|
||||
"phase": context["state"].get("phase"),
|
||||
"project": context["state"].get("project"),
|
||||
"need": context["state"].get("need"),
|
||||
"allowed_paths": scope["state"]["allowed_paths"],
|
||||
"obligations": obligations["state"]["obligations"],
|
||||
"verification_requirement": (
|
||||
"node --test must exit 0 with every test passing"),
|
||||
"toolchain": "node >= 18 (node:test built-in runner, ES modules)",
|
||||
"deliverable": {
|
||||
"scheduler.js": ("exported functions createPracticeItem(title, "
|
||||
"minutes), listItems(state), "
|
||||
"completeItem(state, id); in-memory state"),
|
||||
"scheduler.test.js": ("node:test cases proving create, list "
|
||||
"and complete work"),
|
||||
"package.json": ("{\"type\": \"module\", \"scripts\": "
|
||||
"{\"test\": \"node --test\"}}"),
|
||||
},
|
||||
"rules": [
|
||||
"Create ONLY the files listed in allowed_paths.",
|
||||
"Do not modify or create anything else.",
|
||||
"Verify with: node --test (must pass).",
|
||||
"Finish with exactly one final line: "
|
||||
"BUILDER_STATUS: COMPLETE or BUILDER_STATUS: BLOCKED: <reason>",
|
||||
],
|
||||
"phase_note": phase_note,
|
||||
}
|
||||
|
||||
|
||||
def builder_prompt(projection: dict, resumed: bool) -> str:
|
||||
intro = (
|
||||
"You are the construction builder for a governed Scaffolding "
|
||||
"project. Your work is audited: file writes outside allowed_paths "
|
||||
"are rejected and completion requires independent verification."
|
||||
)
|
||||
if resumed:
|
||||
intro += (
|
||||
" A PREVIOUS builder process was interrupted mid-build. Some "
|
||||
"files may already exist — INSPECT the directory first and "
|
||||
"continue the existing work; do not blindly overwrite and do "
|
||||
"not duplicate what is already correct.")
|
||||
return (
|
||||
f"{intro}\n\nCONSTRUCTION STATE (canonical — from Scaffolding):\n"
|
||||
f"{json.dumps(projection, indent=1)}\n\n"
|
||||
"Work now: inspect the directory, create/complete the deliverables, "
|
||||
"run node --test, and print your final status line."
|
||||
)
|
||||
|
||||
|
||||
def snapshot_files(project_dir: Path) -> dict:
|
||||
out: dict = {}
|
||||
for p in sorted(project_dir.rglob("*")):
|
||||
if p.is_file():
|
||||
out[str(p.relative_to(project_dir))] = p.stat().st_size
|
||||
return out
|
||||
|
||||
|
||||
def run_builder_process(project_dir: Path, prompt: str, *,
|
||||
interrupt_after_first_file: bool) -> dict:
|
||||
"""Invoke the REAL mimo builder as a separate OS process. Returns
|
||||
{terminated_by, exit_code, session_output}."""
|
||||
env = dict(os.environ)
|
||||
proc = subprocess.Popen(
|
||||
[BUILDER_CMD, "run", "-m", BUILDER_MODEL,
|
||||
"--format", "json", prompt],
|
||||
cwd=str(project_dir), env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, start_new_session=True)
|
||||
terminated_by = "completed"
|
||||
if interrupt_after_first_file:
|
||||
log("interrupt armed: watching for first source file...")
|
||||
while True:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
files = [p for p in project_dir.iterdir()
|
||||
if p.suffix in (".mjs", ".js", ".json")]
|
||||
if files:
|
||||
log(f"INTERRUPTING builder: first file landed "
|
||||
f"({files[0].name}) — SIGKILL to the real process")
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
terminated_by = "sigkill_mid_build"
|
||||
break
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
out, err = proc.communicate(timeout=900)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
out, err = proc.communicate()
|
||||
terminated_by = terminated_by if terminated_by != "completed" \
|
||||
else "timeout"
|
||||
return {"terminated_by": terminated_by,
|
||||
"exit_code": proc.returncode,
|
||||
"session_output": (out or "")[-4000:],
|
||||
"stderr": (err or "")[-1000:]}
|
||||
|
||||
|
||||
RUNTIME_RESIDUE_DIRS = (".mimocode", ".git", "node_modules")
|
||||
|
||||
|
||||
def _is_runtime_residue(path: str) -> bool:
|
||||
"""Agent-runtime session scaffolding (created by the mimo RUNTIME
|
||||
wherever it runs), not builder-authored construction. Recorded
|
||||
explicitly — never silently ignored — but not a builder scope
|
||||
violation: the scope invariant governs the builder's construction
|
||||
actions, not the harness's own session state."""
|
||||
top = path.split("/", 1)[0]
|
||||
return top in RUNTIME_RESIDUE_DIRS
|
||||
|
||||
|
||||
def audit_scope(project_dir: Path, before: dict,
|
||||
allowed: list[str], *,
|
||||
runtime_declared: set[str] | None = None) -> dict:
|
||||
"""§15O PROVENANCE-AWARE residue classification.
|
||||
|
||||
The T14 prefix rule (.git/.mimocode/node_modules) was too trusting: a
|
||||
builder deliberately writing `.mimocode/evil` would vanish from the
|
||||
audit. Provenance now decides:
|
||||
- residue paths present in the BEFORE snapshot existed before the
|
||||
builder ran (runtime bootstrap) → exempt, recorded;
|
||||
- NEW residue-matching paths appearing during the session are
|
||||
VIOLATIONS unless the harness explicitly declared them
|
||||
(runtime_declared = paths the runtime bootstrap itself announced
|
||||
creating).
|
||||
"""
|
||||
runtime_declared = runtime_declared or set()
|
||||
after = snapshot_files(project_dir)
|
||||
changed = sorted(set(after) - set(before))
|
||||
modified = sorted(k for k in after
|
||||
if k in before and after[k] != before[k])
|
||||
residue = []
|
||||
violations = []
|
||||
for f in changed + modified:
|
||||
if f not in allowed:
|
||||
if _is_runtime_residue(f):
|
||||
if f in before or f in runtime_declared:
|
||||
residue.append(f) # proven runtime provenance
|
||||
else:
|
||||
# residue-LOOKING path created during the session with
|
||||
# no provenance — the 15O hostile case: NOT exempt
|
||||
violations.append(f)
|
||||
else:
|
||||
violations.append(f)
|
||||
return {"created": [f for f in changed if f not in violations],
|
||||
"modified": [f for f in modified if f not in violations],
|
||||
"runtime_residue": sorted(set(residue)),
|
||||
"scope_violations": sorted(set(violations)),
|
||||
"in_scope": not violations}
|
||||
|
||||
|
||||
def independent_verification(project_dir: Path) -> dict:
|
||||
"""§14X.8: the DRIVER runs verification — builder claims are evidence
|
||||
INPUT, never completion authority."""
|
||||
probe = subprocess.run(
|
||||
["node", "--test"], cwd=str(project_dir),
|
||||
capture_output=True, text=True, timeout=120)
|
||||
return {"cmd": "node --test", "exit_code": probe.returncode,
|
||||
"stdout": (probe.stdout or "")[-2000:],
|
||||
"stderr": (probe.stderr or "")[-500:]}
|
||||
|
||||
|
||||
def complete_lifecycle(iface: ci.ConstructionInterface, contract_id: str,
|
||||
scope_audit: dict, verification: dict,
|
||||
builder_status: str) -> dict:
|
||||
items = [{"entity": "builder-session",
|
||||
"source": "independent_driver",
|
||||
"detail": (f"builder={BUILDER_MODEL} claimed="
|
||||
f"{builder_status} scope_violations="
|
||||
f"{scope_audit['scope_violations']} "
|
||||
f"verify_exit={verification['exit_code']}")}]
|
||||
items += [{"entity": f, "source": "independent_driver",
|
||||
"detail": "created in scope"}
|
||||
for f in scope_audit["created"]]
|
||||
items += [{"entity": f, "source": "independent_driver",
|
||||
"detail": "modified in scope"}
|
||||
for f in scope_audit["modified"]]
|
||||
obs = iface.report_observation(contract_id, items)
|
||||
assert obs.get("success"), obs.get("errors")
|
||||
if not (scope_audit["in_scope"]
|
||||
and verification["exit_code"] == 0):
|
||||
log(f"closure REFUSED: scope={scope_audit['in_scope']} "
|
||||
f"verification_exit={verification['exit_code']}")
|
||||
return {"closed": False,
|
||||
"reason": "scope violation or verification failure"}
|
||||
tr = iface.transition(contract_id, "verifying",
|
||||
evidence_ref="independent node --test run")
|
||||
assert tr.get("success"), tr.get("errors")
|
||||
v = iface.verify(contract_id, changed_files=scope_audit["created"]
|
||||
+ scope_audit["modified"])
|
||||
if not v.get("success"):
|
||||
log(f"verify refused: {v.get('errors')}")
|
||||
return {"closed": False, "reason": "verify refused"}
|
||||
c = iface.close(contract_id, observed_evidence=[
|
||||
{"kind": "verification", "cmd": "node --test",
|
||||
"exit_code": 0, "source": "independent_driver_run"}],
|
||||
scope_files=scope_audit["created"] + scope_audit["modified"])
|
||||
return {"closed": bool(c.get("success")),
|
||||
"close_envelope": {k: c.get(k) for k in
|
||||
("success", "errors", "ids", "state")}}
|
||||
|
||||
|
||||
def discover_contract_id(store: Path) -> tuple[str, str] | None:
|
||||
"""§15U: delegates to the canonical interface
|
||||
(ConstructionInterface.discover_active_contract). This shim stays only
|
||||
so existing driver call sites keep working."""
|
||||
from rune import construction_interface as _ci
|
||||
env = _ci.ConstructionInterface.discover_active_contract(str(store))
|
||||
if not env.get("success"):
|
||||
return None
|
||||
return env["ids"]["project"], env["ids"]["contract_id"]
|
||||
|
||||
|
||||
def seed_requirement_spec(store: Path, project: str) -> str:
|
||||
"""Govern the requirement as a contract on the project's surface
|
||||
(identity before repository §7): the user request IS the requirement
|
||||
evidence. The Need then references this contract explicitly — the
|
||||
capability-fit engine resolves REUSE of the SPECIFICATION (the build
|
||||
is governed by satisfying it). Maturity DECLARED: a requirement, not
|
||||
existing code — nothing manufactured."""
|
||||
proj = sc.Scaffold(str(store / "projects" / project), project)
|
||||
doc = proj.load()
|
||||
if ss.entity(doc, "contract:practice-items-spec") is None:
|
||||
ss.add_entity(
|
||||
doc, id="contract:practice-items-spec", kind="contract",
|
||||
identities={"objective": USER_REQUEST},
|
||||
evidence_refs=["t14-user-request"])
|
||||
ss.add_entity(
|
||||
doc, id="capability:practice-scheduler", kind="capability",
|
||||
identities={"planned_surface": list(EXPECTED_SCOPE)},
|
||||
maturity="DECLARED", evidence_refs=["t14-user-request"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:practice-scheduler",
|
||||
to_id="contract:practice-items-spec",
|
||||
evidence_refs=["t14-user-request"])
|
||||
proj.commit(doc, what="seed requirement spec: practice items "
|
||||
"create/list/complete (user request)")
|
||||
return "contract:practice-items-spec"
|
||||
|
||||
|
||||
def cmd_start(store: Path, project_dir: Path, interrupt: bool) -> int:
|
||||
before = snapshot_files(project_dir)
|
||||
staged = classify_and_activate(store, USER_REQUEST)
|
||||
project = staged["activation"]["project"]
|
||||
assert staged["activation"]["mode"] == "PROJECT_BUILD", \
|
||||
f"expected PROJECT_BUILD, got {staged['activation']['mode']}"
|
||||
spec_contract = seed_requirement_spec(store, project)
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL,
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
sub = iface.submit_need(
|
||||
USER_REQUEST, origin="rune_native",
|
||||
constraints={"environment": {"os": "linux", "runtime": "node>=18",
|
||||
"runner": "node --test"},
|
||||
"contracts": [spec_contract]},
|
||||
affected_surface=list(EXPECTED_SCOPE),
|
||||
evidence_refs=["t14-user-request", "node-v22-installed"],
|
||||
approval_context="t14 user-approved fixture")
|
||||
assert sub.get("success"), sub.get("errors")
|
||||
contract_id = sub["ids"]["contract_id"]
|
||||
log(f"contract: {contract_id}")
|
||||
for phase in ("approved", "building"):
|
||||
tr = iface.transition(contract_id, phase,
|
||||
evidence_ref=f"t14 {phase}")
|
||||
assert tr.get("success"), tr.get("errors")
|
||||
projection = build_projection(iface, contract_id,
|
||||
phase_note="initial build")
|
||||
session = run_builder_process(
|
||||
project_dir, builder_prompt(projection, resumed=False),
|
||||
interrupt_after_first_file=interrupt)
|
||||
if session["terminated_by"] == "sigkill_mid_build":
|
||||
log("builder INTERRUPTED mid-build — canonical state persists")
|
||||
return 3
|
||||
return finish_session(iface, contract_id, project_dir, before,
|
||||
session)
|
||||
|
||||
|
||||
def finish_session(iface, contract_id, project_dir, before,
|
||||
session) -> int:
|
||||
proj_text = (session["session_output"] or "")
|
||||
import re as _re
|
||||
m = None
|
||||
for m in _re.finditer(r"BUILDER_STATUS:\s*([A-Z]+)", proj_text):
|
||||
pass
|
||||
builder_status = (m.group(1) + " (final)") if m else "NOT_STATED"
|
||||
scope_audit = audit_scope(project_dir, before,
|
||||
list(EXPECTED_SCOPE))
|
||||
verification = independent_verification(project_dir)
|
||||
result = complete_lifecycle(iface, contract_id, scope_audit,
|
||||
verification, builder_status)
|
||||
summary = {
|
||||
"builder_model": BUILDER_MODEL,
|
||||
"builder_process": "mimo run (separate OS process)",
|
||||
"terminated_by": session["terminated_by"],
|
||||
"builder_claimed": builder_status,
|
||||
"scope_audit": scope_audit,
|
||||
"verification": {"cmd": verification["cmd"],
|
||||
"exit_code": verification["exit_code"]},
|
||||
"lifecycle": result,
|
||||
}
|
||||
print(json.dumps(summary, indent=1))
|
||||
return 0 if result.get("closed") else 1
|
||||
|
||||
|
||||
def cmd_resume(store: Path, project_dir: Path) -> int:
|
||||
"""14E: FRESH process. Canonical state ONLY — no transcript, no
|
||||
memory of the previous session."""
|
||||
found = discover_contract_id(store)
|
||||
assert found, "no contract found in canonical state"
|
||||
project, contract_id = found
|
||||
log(f"fresh builder resume: project={project} "
|
||||
f"contract={contract_id} (canonical only)")
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL + "-resumed",
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
context = iface.construction_context(contract_id)
|
||||
assert context["state"]["active"], "canonical state shows no active build"
|
||||
log(f"recovered: phase={context['state']['phase']} "
|
||||
f"scope={context['state']['scope']}")
|
||||
before = snapshot_files(project_dir)
|
||||
projection = build_projection(
|
||||
iface, contract_id,
|
||||
phase_note="resumed after real builder interruption")
|
||||
session = run_builder_process(
|
||||
project_dir, builder_prompt(projection, resumed=True),
|
||||
interrupt_after_first_file=False)
|
||||
return finish_session(iface, contract_id, project_dir, before,
|
||||
session)
|
||||
|
||||
|
||||
def cmd_status(store: Path) -> int:
|
||||
found = discover_contract_id(store)
|
||||
if not found:
|
||||
print(json.dumps({"active": False}))
|
||||
return 0
|
||||
project, contract_id = found
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="inspector",
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
print(json.dumps(iface.construction_context(contract_id)["state"],
|
||||
indent=1, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("mode", choices=["start", "resume", "status"])
|
||||
ap.add_argument("--store", required=True)
|
||||
ap.add_argument("--project-dir", required=False)
|
||||
ap.add_argument("--interrupt-after-first-file", action="store_true")
|
||||
args = ap.parse_args()
|
||||
store, project_dir = Path(args.store).resolve(), \
|
||||
Path(args.project_dir).resolve() if args.project_dir else None
|
||||
project_dir.mkdir(parents=True, exist_ok=True) if project_dir else None
|
||||
if args.mode == "start":
|
||||
return cmd_start(store, project_dir,
|
||||
args.interrupt_after_first_file)
|
||||
if args.mode == "resume":
|
||||
return cmd_resume(store, project_dir)
|
||||
return cmd_status(store)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
"""t15_builder_session.py — T15 §15Q: REAL event-driven builder proof.
|
||||
|
||||
The difference from T14, enforced by construction:
|
||||
|
||||
launch → register WAITING_EVENT → YIELD (select on the bridge FIFO)
|
||||
→ builder exit event → resume immediately → reload canonical state
|
||||
→ readiness/exit canaries → independent verification → continuation
|
||||
|
||||
NO sleep/poll loops anywhere on the normal path. The watchdog is only a
|
||||
failure detector (lost event / dead process), never the scheduler.
|
||||
|
||||
Modes:
|
||||
start classify → activate → contract → real builder → event wait
|
||||
→ exit canary → verification → evidence-backed close
|
||||
interrupt like start, but the builder process group is SIGKILLed at
|
||||
the first source file → PROCESS_LOST → canonical state
|
||||
persists for a fresh builder (run `resume` next)
|
||||
resume FRESH controller + fresh builder from canonical state only
|
||||
status print canonical construction state
|
||||
|
||||
The builder is the real model process (mimo run — Verboo per the standing
|
||||
provider policy). Builder identity is recorded, never trusted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import t14_builder_session as t14 # noqa: E402 — reuse the proven pieces
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune.event_wait import DEFAULT_DAEMON_URL # noqa: E402
|
||||
from rune.provider_context import ProviderUnresolved, require_model # noqa: E402
|
||||
|
||||
# T16 §16F: NO silent default provider — resolve from the ambient provider
|
||||
# context (T15_BUILDER_MODEL, falling back to MIMO_MODEL); loud failure
|
||||
# with exit code 3 when the provider layer has not selected one.
|
||||
try:
|
||||
BUILDER_MODEL = os.environ.get("T15_BUILDER_MODEL", "").strip() \
|
||||
or require_model()
|
||||
except ProviderUnresolved as _exc:
|
||||
import sys as _sys
|
||||
_sys.stderr.write(str(_exc) + "\n")
|
||||
raise SystemExit(3) # 3 = PROVIDER_UNRESOLVED (no silent fallback)
|
||||
DAEMON_URL = os.environ.get("T15_DAEMON_URL", DEFAULT_DAEMON_URL)
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[t15] {msg}", flush=True)
|
||||
|
||||
|
||||
def readiness_canary(toolchain_hint: str) -> dict:
|
||||
"""§15F: may the build stage begin? Executable checks, not optimism.
|
||||
Runtime checks derive from the verifier (15R generalisation)."""
|
||||
checks = []
|
||||
runtimes = (["node"] if "node" in VERIFIER else []) + (["python3"] if "python" in VERIFIER else [])
|
||||
for rt in runtimes:
|
||||
r = subprocess.run([rt, "--version"], capture_output=True, text=True)
|
||||
checks.append({"name": f"{rt}_present", "passed": r.returncode == 0,
|
||||
"evidence": (r.stdout or "").strip()[:30]})
|
||||
r2 = subprocess.run(["mimo", "--version"], capture_output=True, text=True)
|
||||
checks.append({"name": "builder_cli_present", "passed": r2.returncode == 0,
|
||||
"evidence": (r2.stdout or "").strip()[:40]})
|
||||
try:
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(DAEMON_URL + "/health", timeout=3) as resp:
|
||||
ok = resp.status == 200
|
||||
checks.append({"name": "event_bridge_healthy", "passed": ok,
|
||||
"evidence": DAEMON_URL})
|
||||
except Exception as e: # noqa: BLE001
|
||||
checks.append({"name": "event_bridge_healthy", "passed": False,
|
||||
"evidence": str(e)[:80]})
|
||||
verdict = "READY" if all(c["passed"] for c in checks) else "BLOCKED"
|
||||
return {"canary": "readiness", "verdict": verdict, "checks": checks}
|
||||
|
||||
|
||||
# 15R: the driver is construction INFRASTRUCTURE — the request/scope are
|
||||
# parameters, never scheduler-specific constants. Defaults preserve the
|
||||
# proven fixture.
|
||||
REQUEST = os.environ.get(
|
||||
"T15_REQUEST",
|
||||
"Build me a small standalone practice scheduler that can create, "
|
||||
"list and complete practice items.")
|
||||
SCOPE = [s for s in os.environ.get(
|
||||
"T15_SCOPE", "scheduler.js,scheduler.test.js,package.json").split(",")
|
||||
if s]
|
||||
PROJECT_NAME = os.environ.get("T15_PROJECT_NAME", "")
|
||||
SPEC_CONTRACT = os.environ.get("T15_SPEC_CONTRACT",
|
||||
"contract:practice-items-spec")
|
||||
TOOLCHAIN = os.environ.get("T15_TOOLCHAIN",
|
||||
"node >= 18 (node:test built-in runner, ES modules)")
|
||||
VERIFIER = os.environ.get("T15_VERIFIER", "node --test")
|
||||
|
||||
|
||||
def setup_construction(store: Path, project_dir: Path) -> tuple[str, str]:
|
||||
"""classify → activate → seed spec → contract → transitions. Reuses
|
||||
the proven T14 canonical path unchanged."""
|
||||
staged = t14.classify_and_activate(store, REQUEST)
|
||||
project = staged["activation"]["project"]
|
||||
assert staged["activation"]["mode"] == "PROJECT_BUILD"
|
||||
_seed_spec(store, project)
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL,
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
sub = iface.submit_need(
|
||||
REQUEST, origin="rune_native",
|
||||
constraints={"environment": {"os": "linux",
|
||||
"runtime": TOOLCHAIN,
|
||||
"runner": VERIFIER},
|
||||
"contracts": [SPEC_CONTRACT]},
|
||||
affected_surface=list(SCOPE),
|
||||
evidence_refs=["t15-user-request", "node-v22-installed"],
|
||||
approval_context="t15 user-approved fixture")
|
||||
assert sub.get("success"), sub.get("errors")
|
||||
contract_id = sub["ids"]["contract_id"]
|
||||
for phase in ("approved", "building"):
|
||||
tr = iface.transition(contract_id, phase, evidence_ref=f"t15 {phase}")
|
||||
assert tr.get("success"), tr.get("errors")
|
||||
log(f"contract {contract_id} → building")
|
||||
return project, contract_id
|
||||
|
||||
|
||||
def _seed_spec(store: Path, project: str) -> str:
|
||||
"""Govern the requirement as a contract on the project surface
|
||||
(generic — request/scope come from parameters)."""
|
||||
proj = sc.Scaffold(str(store / "projects" / project), project)
|
||||
doc = proj.load()
|
||||
if ss.entity(doc, SPEC_CONTRACT) is None:
|
||||
ss.add_entity(doc, id=SPEC_CONTRACT, kind="contract",
|
||||
identities={"objective": REQUEST},
|
||||
evidence_refs=["t15-request"])
|
||||
ss.add_entity(doc, id="capability:planned-" + project[:24],
|
||||
kind="capability", maturity="DECLARED",
|
||||
evidence_refs=["t15-request"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:planned-" + project[:24],
|
||||
to_id=SPEC_CONTRACT, evidence_refs=["t15-request"])
|
||||
proj.commit(doc, what="seed requirement spec (user request)")
|
||||
return SPEC_CONTRACT
|
||||
|
||||
|
||||
def run_build_via_events(project_dir: Path, operation_id: str,
|
||||
projection: dict, contract_id: str,
|
||||
interrupt_after_first_file: bool) -> dict:
|
||||
"""THE 15Q core: real builder under the event notifier; the driver
|
||||
YIELDS on the bridge FIFO. No sleep. Watchdog = failure detector."""
|
||||
ew.register_process_wait(
|
||||
operation_id, contract_id=contract_id,
|
||||
project="t15-fixture", objective=REQUEST,
|
||||
watched_for="builder process exit event", watchdog_secs=900)
|
||||
# op marker rides the prompt (last argv) so cmdline liveness can find
|
||||
# the build even if node regroups its children (T15Q live finding)
|
||||
builder_cmd = ["mimo", "run", "-m", BUILDER_MODEL, "--format", "json",
|
||||
t14.builder_prompt(projection, resumed=False)
|
||||
+ f" [op:{operation_id}]"]
|
||||
notifier = ew.run_builder_under_events(builder_cmd, operation_id,
|
||||
daemon_url=DAEMON_URL)
|
||||
log(f"builder launched (pgid {notifier.pid}); driver YIELDS on the "
|
||||
f"event bridge — no polling")
|
||||
if interrupt_after_first_file:
|
||||
# kill at first source file: the interruption is real, the event
|
||||
# never arrives (the notifier dies with the group) — the watchdog
|
||||
# must classify PROCESS_LOST, never silently wait.
|
||||
# TEST_TIMING: this 0.5s poll exists ONLY on the deliberate
|
||||
# interrupt path (killing at a checkpoint), never on the normal
|
||||
# continuation path.
|
||||
while True:
|
||||
files = [p for p in project_dir.iterdir()
|
||||
if p.suffix in (".mjs", ".js", ".json")]
|
||||
if files:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
log("INTERRUPTING builder (first file landed) — SIGKILL group")
|
||||
os.killpg(notifier.pid, signal.SIGKILL)
|
||||
result = ew.await_resume_event(operation_id, watchdog_secs=900,
|
||||
pgid=notifier.pid)
|
||||
return result
|
||||
|
||||
|
||||
def finish_via_canaries(store: Path, project: str, contract_id: str,
|
||||
project_dir: Path, before: dict,
|
||||
resume_note: str) -> int:
|
||||
"""Reload canonical state → scope audit → INDEPENDENT verification
|
||||
(15G test truth) → evidence-backed lifecycle."""
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL, project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
ctx = iface.construction_context(contract_id)
|
||||
assert ctx["state"]["active"] or ctx["state"]["phase"] == "closed"
|
||||
log(f"canonical state: phase={ctx['state']['phase']} "
|
||||
f"scope={ctx['state']['scope']}")
|
||||
# exit canary: scope audit with provenance-aware residue (15O)
|
||||
audit = t14.audit_scope(project_dir, before, list(SCOPE))
|
||||
# 15G: verification truth — node --test must actually run tests
|
||||
# 15G test truth via the CONTRACT's verifier (parameterized — 15R
|
||||
# generalisation: python unittest/pytest, node --test, etc.)
|
||||
truth = subprocess.run(
|
||||
["bash", "-c", VERIFIER], cwd=str(project_dir),
|
||||
capture_output=True, text=True, timeout=300)
|
||||
import re
|
||||
count = None
|
||||
for pat in (r"# pass (\d+)", # node --test TAP summary
|
||||
r"(\d+) passed", # pytest
|
||||
r"Ran (\d+) tests"): # python unittest
|
||||
m = re.search(pat, (truth.stdout or "") + (truth.stderr or ""))
|
||||
if m:
|
||||
count = int(m.group(1))
|
||||
break
|
||||
test_count = count if count is not None else 0
|
||||
log(f"verification: exit={truth.returncode} tests={test_count}")
|
||||
if test_count == 0:
|
||||
log("FALSE GREEN REFUSED: zero tests executed (15G)")
|
||||
return 1
|
||||
if audit["scope_violations"]:
|
||||
log(f"closure REFUSED: scope violations {audit['scope_violations']}")
|
||||
return 1
|
||||
builder_status = "UNKNOWN"
|
||||
result = t14.complete_lifecycle(iface, contract_id, audit,
|
||||
{"cmd": "node --test",
|
||||
"exit_code": truth.returncode},
|
||||
builder_status)
|
||||
summary = {
|
||||
"event_driven": True,
|
||||
"builder_model": BUILDER_MODEL,
|
||||
"resume_note": resume_note,
|
||||
"scope_audit": audit,
|
||||
"verification": {"exit_code": truth.returncode,
|
||||
"test_count": test_count},
|
||||
"lifecycle": result,
|
||||
}
|
||||
print(json.dumps(summary, indent=1))
|
||||
return 0 if result.get("closed") else 1
|
||||
|
||||
|
||||
def discover(store: Path) -> tuple[str, str] | None:
|
||||
"""§15U: delegates to the canonical interface discovery (via the t14
|
||||
shim — one hop, no duplication)."""
|
||||
return t14.discover_contract_id(store)
|
||||
|
||||
|
||||
def cmd_start(store: Path, project_dir: Path, interrupt: bool) -> int:
|
||||
ready = readiness_canary("node")
|
||||
assert ready["verdict"] == "READY", f"readiness BLOCKED: {ready['checks']}"
|
||||
log(f"readiness READY ({len(ready['checks'])} checks)")
|
||||
before = t14.snapshot_files(project_dir)
|
||||
project, contract_id = setup_construction(store, project_dir)
|
||||
operation_id = f"t15-{contract_id}"
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL, project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
projection = t14.build_projection(iface, contract_id,
|
||||
phase_note="initial build")
|
||||
result = run_build_via_events(project_dir, operation_id, projection,
|
||||
contract_id, interrupt)
|
||||
if result["kind"] == "watchdog":
|
||||
# §15E canonical-state-wins: process-table absence is NOT truth
|
||||
# (detached builders re-parent their workers — T15Q live finding).
|
||||
# The durable waitstore decides: a recorded verdict means the
|
||||
# builder completed and the event WAS processed.
|
||||
rec = ew.resume_or_recover(operation_id)
|
||||
if rec["kind"] == "event":
|
||||
log(f"watchdog fired but canonical state shows the build "
|
||||
f"completed (verdict={rec['wait'].get('verdict')}) — "
|
||||
f"continuing from authority")
|
||||
return finish_via_canaries(store, project, contract_id,
|
||||
project_dir, before,
|
||||
"watchdog→canonical recovery")
|
||||
log("PROCESS_LOST: no event, no durable verdict — fresh builder "
|
||||
"resumes from authority only")
|
||||
return 3
|
||||
wait = result.get("wait", {})
|
||||
log(f"event-driven resume: verdict={wait.get('verdict')} "
|
||||
f"exit={wait.get('process_event_evidence', {}).get('exit_code')}")
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "event-driven completion")
|
||||
|
||||
|
||||
def cmd_resume(store: Path, project_dir: Path) -> int:
|
||||
found = discover(store)
|
||||
assert found, "no contract in canonical state"
|
||||
project, contract_id = found
|
||||
log(f"FRESH builder resume: {project}/{contract_id} (canonical only)")
|
||||
before = t14.snapshot_files(project_dir)
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL + "-resumed",
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
projection = t14.build_projection(
|
||||
iface, contract_id, phase_note="resumed after real interruption")
|
||||
operation_id = f"t15-resume-{contract_id}"
|
||||
ew.register_process_wait(
|
||||
operation_id, contract_id=contract_id, project=project,
|
||||
objective=REQUEST, watched_for="builder exit event",
|
||||
watchdog_secs=900)
|
||||
builder_cmd = ["mimo", "run", "-m", BUILDER_MODEL, "--format", "json",
|
||||
t14.builder_prompt(projection, resumed=True)
|
||||
+ f" [op:{operation_id}]"]
|
||||
notifier = ew.run_builder_under_events(builder_cmd, operation_id,
|
||||
daemon_url=DAEMON_URL)
|
||||
log("driver YIELDS on the bridge — no polling")
|
||||
result = ew.await_resume_event(operation_id, watchdog_secs=900,
|
||||
pgid=notifier.pid)
|
||||
if result["kind"] == "watchdog":
|
||||
rec = ew.resume_or_recover(operation_id)
|
||||
if rec["kind"] == "event":
|
||||
log(f"watchdog fired but canonical state shows the resume "
|
||||
f"build completed (verdict={rec['wait'].get('verdict')})")
|
||||
return finish_via_canaries(store, project, contract_id,
|
||||
project_dir, before,
|
||||
"resume via canonical recovery")
|
||||
log("PROCESS_LOST on resume — UNKNOWN_INTERRUPTION")
|
||||
return 4
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "fresh-builder event-driven resume")
|
||||
|
||||
|
||||
def cmd_finish(store: Path, project_dir: Path) -> int:
|
||||
"""Complete lifecycle from CANONICAL state only (waitstore verdict +
|
||||
filesystem reality). Used after a watchdog→canonical recovery."""
|
||||
found = discover(store)
|
||||
assert found, "no contract in canonical state"
|
||||
project, contract_id = found
|
||||
before = t14.snapshot_files(project_dir)
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "canonical-only completion")
|
||||
|
||||
|
||||
def cmd_status(store: Path) -> int:
|
||||
found = discover(store)
|
||||
if not found:
|
||||
print(json.dumps({"active": False}))
|
||||
return 0
|
||||
project, contract_id = found
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="inspector", project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
print(json.dumps(iface.construction_context(contract_id)["state"],
|
||||
indent=1, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("mode", choices=["start", "resume", "finish",
|
||||
"status"])
|
||||
ap.add_argument("--store", required=True)
|
||||
ap.add_argument("--project-dir", required=False)
|
||||
ap.add_argument("--interrupt-after-first-file", action="store_true")
|
||||
args = ap.parse_args()
|
||||
store = Path(args.store).resolve()
|
||||
project_dir = (Path(args.project_dir).resolve()
|
||||
if args.project_dir else None)
|
||||
if project_dir:
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.mode == "start":
|
||||
return cmd_start(store, project_dir,
|
||||
args.interrupt_after_first_file)
|
||||
if args.mode == "resume":
|
||||
return cmd_resume(store, project_dir)
|
||||
if args.mode == "finish":
|
||||
return cmd_finish(store, project_dir)
|
||||
return cmd_status(store)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,6 +6,7 @@ New code must be type-clean; existing code improves as debt is paid down.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -23,12 +24,35 @@ def main() -> int:
|
||||
# not work on all platforms). Fall back to sys.executable -m.
|
||||
pyright_bin = Path(sys.executable).parent / "pyright"
|
||||
cmd = [str(pyright_bin)] if pyright_bin.is_file() else [sys.executable, "-m", "pyright"]
|
||||
# §14H LOCAL-VS-CI NORMALIZATION (proven 2026-09-15): pyright resolves
|
||||
# the python environment for import analysis, and a developer's
|
||||
# user-site packages (~/.local/lib/pythonX/site-packages) leak into it
|
||||
# — nick: 124 errors, runner user: 117 on the IDENTICAL checkout+venv.
|
||||
# PYTHONNOUSERSITE=1 pins both contexts to the same effective
|
||||
# environment (verified: identical 117-error arithmetic). Local
|
||||
# preflight now predicts CI instead of diverging from it.
|
||||
# §16U ROOT-CAUSE COMPLETION (T16): PYTHONNOUSERSITE alone is NOT
|
||||
# sufficient when a DIFFERENT venv is active (e.g. an ad-hoc CI venv):
|
||||
# the pyright wrapper resolves the ambient VIRTUAL_ENV/PATH
|
||||
# interpreter and analyzes against ITS site-packages — reproduced
|
||||
# live (117 vs 124 on the same tree, driven by venv-activation state
|
||||
# alone). The CI runner resolves the AMBIENT python (no venv is
|
||||
# activated in the type-check job), so the ratchet neutralizes every
|
||||
# ambient-venv signal before invoking pyright — analysis context is
|
||||
# now identical to CI regardless of the calling shell.
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k not in ("VIRTUAL_ENV",)}
|
||||
env["PYTHONNOUSERSITE"] = "1"
|
||||
env["PATH"] = os.pathsep.join(
|
||||
p for p in env.get("PATH", "").split(os.pathsep)
|
||||
if "ci-venv" not in p and ".type-venv" not in p and "venv/bin" not in p)
|
||||
result = subprocess.run(
|
||||
cmd + [str(ROOT / "src" / "rune"), "--outputjson"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
cwd=str(ROOT),
|
||||
env=env,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
summary = data.get("summary", {})
|
||||
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.66.17"
|
||||
__version__ = "0.66.68"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
@@ -231,7 +231,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.66.17"' in rune_version,
|
||||
'__version__ = "0.66.68"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -93,6 +93,31 @@ def main() -> int:
|
||||
"if: always()" in required_gate and "needs: [lint, unit-tests, canary" in required_gate,
|
||||
failures,
|
||||
)
|
||||
# RELEASE-SAFETY (2026-09-13, after 0.66.28/29 red promotions): the
|
||||
# required-checks publish step previously echoed success UNCONDITIONALLY
|
||||
# (read /tmp/need_* files that no step wrote). The verdict MUST come
|
||||
# from the needs context — this canary locks that class shut.
|
||||
publish_section = required_gate.split("Publish protected-branch gate", 1)[-1]
|
||||
check(
|
||||
"required gate verdict evaluates needs results (not unconditional echo)",
|
||||
"needs.lint.result == 'success'" in publish_section
|
||||
and "needs['type-check'].result == 'success'" in publish_section
|
||||
and "needs['unit-tests'].result == 'success'" in publish_section,
|
||||
failures,
|
||||
)
|
||||
check(
|
||||
"required gate fails closed when any check is not success",
|
||||
"!= 'success'" in publish_section and "exit 1" in publish_section,
|
||||
failures,
|
||||
)
|
||||
check(
|
||||
"no unconditional success echo without a needs-conditioned if",
|
||||
not (
|
||||
"All required CI checks passed" in publish_section
|
||||
and "needs.lint.result == 'success'" not in publish_section
|
||||
),
|
||||
failures,
|
||||
)
|
||||
check(
|
||||
"required gate depends on unit tests and verification jobs",
|
||||
"needs: [lint, unit-tests, canary" in text,
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""capability_fit.py — Requirement-fit capability discovery + gap decision.
|
||||
|
||||
SCAFFOLD-TRANCHE-003. Layer over the Tranche 2 substrate (structural_store,
|
||||
scaffold, spine). NOT a second graph: discovery queries existing structural
|
||||
authorities (Rune Spine / Project Scaffold stores) and produces candidates;
|
||||
fit and resolution are derived verdicts with recorded evidence.
|
||||
|
||||
Architecture:
|
||||
|
||||
CapabilityRequirement (open-world object)
|
||||
↓
|
||||
discover_candidates(store_doc) DETERMINISTIC — structural only
|
||||
↓
|
||||
evaluate_fit(requirement, candidate) DETERMINISTIC first;
|
||||
bounded LLM seam recorded, never
|
||||
authoritative alone
|
||||
↓
|
||||
decide(requirement, fits) resolution surface:
|
||||
SATISFIED / REUSE / CONFIGURE /
|
||||
COMPOSE / ADAPT_EXISTING /
|
||||
ACQUIRE_EXTERNAL / BUILD_NEW /
|
||||
INSUFFICIENT_EVIDENCE
|
||||
|
||||
Core invariants (tranche brief §5, §27):
|
||||
- BUILD_NEW is NEVER the default and is never silently chosen from
|
||||
insufficient evidence.
|
||||
- Structural discovery produces CANDIDATES; names alone are never fit.
|
||||
- A stale/superseded/degraded candidate is not silently reusable.
|
||||
- BUILD BUT DON'T BREAK: adaptation selected only when existing consumers
|
||||
are provably unaffected; brittle coupling forces BUILD_NEW (with
|
||||
rationale) or COMPOSE.
|
||||
- When an LLM contributes interpretation, provider/model/reason/evidence/
|
||||
confidence are RECORDED; its prose alone never establishes authority.
|
||||
- Memory is never a structural authority here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune.structural_store import entity, relationships, resolve_status
|
||||
|
||||
__all__ = [
|
||||
"CapabilityRequirement",
|
||||
"RESOLUTIONS",
|
||||
"discover_candidates",
|
||||
"evaluate_fit",
|
||||
"decide",
|
||||
"record_semantic_fit",
|
||||
]
|
||||
|
||||
RESOLUTIONS = (
|
||||
"SATISFIED",
|
||||
"REUSE",
|
||||
"CONFIGURE",
|
||||
"COMPOSE",
|
||||
"ADAPT_EXISTING",
|
||||
"ACQUIRE_EXTERNAL",
|
||||
"BUILD_NEW",
|
||||
"INSUFFICIENT_EVIDENCE",
|
||||
)
|
||||
|
||||
# relationship types treated as "candidate implements/exposes this contract"
|
||||
_PROVIDES = {"implements", "exposes", "provides", "serves"}
|
||||
_CONSUMES = {"consumes", "depends_on"}
|
||||
# entity kinds eligible as capability candidates (open-world: unknown kinds
|
||||
# with providing relationships are also eligible — see discover_candidates)
|
||||
_BASE_KINDS = {"capability", "module", "component", "service", "cli", "tool", "workflow"}
|
||||
# maturity rank for reuse-worthiness (shared ladder from structural_store)
|
||||
_MATURITY_ORDER = ["DECLARED", "PRESENT", "WIRED", "REACHABLE", "EXERCISED", "LIVE_PROVEN"]
|
||||
_MATURITY_RANK = {n: i for i, n in enumerate(_MATURITY_ORDER)}
|
||||
|
||||
|
||||
# ── Capability Requirement ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CapabilityRequirement:
|
||||
"""Open-world structured requirement. No fixed ontology: `props` carries
|
||||
any constraint the need expresses; a small set of FIRST-CLASS keys are
|
||||
interpreted deterministically (contracts, environment, mandatory) and
|
||||
everything else roundtrips as data."""
|
||||
|
||||
FIRST_CLASS = (
|
||||
"objective",
|
||||
"contracts",
|
||||
"environment",
|
||||
"mandatory",
|
||||
"optional",
|
||||
"target_project",
|
||||
"inputs",
|
||||
"outputs",
|
||||
"performance",
|
||||
"reliability",
|
||||
"deployment",
|
||||
"integration",
|
||||
"user_constraints",
|
||||
"evidence_refs",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
objective: str,
|
||||
*,
|
||||
contracts: list[str] | None = None,
|
||||
environment: dict | None = None,
|
||||
mandatory: list[str] | None = None,
|
||||
optional: list[str] | None = None,
|
||||
**props,
|
||||
):
|
||||
self.objective = objective
|
||||
self.contracts = sorted(contracts or []) # required interfaces
|
||||
self.environment = dict(environment or {}) # e.g. {"os": "linux"}
|
||||
self.mandatory = sorted(mandatory or [])
|
||||
self.optional = sorted(optional or [])
|
||||
self.props = dict(props) # open-world
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
"objective": self.objective,
|
||||
"contracts": self.contracts,
|
||||
"environment": self.environment,
|
||||
"mandatory": self.mandatory,
|
||||
"optional": self.optional,
|
||||
}
|
||||
d.update(self.props)
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "CapabilityRequirement":
|
||||
known = {k: d.pop(k) for k in cls.FIRST_CLASS if k in d}
|
||||
return cls(known.pop("objective"), **{**known, **d})
|
||||
|
||||
|
||||
# ── semantic-fit seam (recorded, never authoritative alone) ───────────────
|
||||
|
||||
|
||||
def record_semantic_fit(
|
||||
provider: str, model: str, reason: str, evidence: list[str], confidence: float, interpretation: str
|
||||
) -> dict:
|
||||
"""Record a bounded LLM semantic interpretation contributing to fit.
|
||||
The record is EVIDENCE, not authority: decide() treats it as supporting
|
||||
context that must agree with deterministic facts. Never silent."""
|
||||
if not provider or not model:
|
||||
raise ValueError("semantic fit record requires provider+model")
|
||||
if not 0.0 <= confidence <= 1.0:
|
||||
raise ValueError("confidence must be in [0,1]")
|
||||
return {
|
||||
"kind": "semantic_fit",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"reason": reason,
|
||||
"evidence": sorted(evidence),
|
||||
"confidence": confidence,
|
||||
"interpretation": interpretation[:500],
|
||||
}
|
||||
|
||||
|
||||
# ── structural discovery (deterministic) ──────────────────────────────────
|
||||
|
||||
|
||||
def _provides_contracts(doc: dict, eid: str) -> set[str]:
|
||||
out = set()
|
||||
for r in relationships(doc, from_id=eid):
|
||||
if r["type"] in _PROVIDES:
|
||||
out.add(r["to"])
|
||||
return out
|
||||
|
||||
|
||||
def _consumers(doc: dict, eid: str) -> set[str]:
|
||||
out = set()
|
||||
for r in relationships(doc, to_id=eid):
|
||||
if r["type"] in {"consumes", "consumed_by", "depends_on"}:
|
||||
out.add(r["from"])
|
||||
return out
|
||||
|
||||
|
||||
def discover_candidates(doc: dict, req: CapabilityRequirement) -> list[dict]:
|
||||
"""Deterministic candidate discovery over ONE structural store doc.
|
||||
A candidate is any entity that (a) is a known capability-ish kind, OR
|
||||
(b) has providing relationships (open-world kinds stay discoverable).
|
||||
Returns candidates with their structural evidence attached. Names are
|
||||
NOT fit — every candidate must still pass evaluate_fit."""
|
||||
candidates = []
|
||||
for e in doc["entities"]:
|
||||
eid = e["id"]
|
||||
provides = _provides_contracts(doc, eid)
|
||||
eligible = e["kind"] in _BASE_KINDS or bool(provides)
|
||||
if not eligible:
|
||||
continue
|
||||
# environment filter: candidate must not CONTRADICT a stated
|
||||
# environment constraint (missing info is not contradiction)
|
||||
env = e.get("identities", {}).get("environment", {})
|
||||
if isinstance(env, dict) and req.environment:
|
||||
contradicts = any(k in env and req.environment.get(k) not in (None, env[k]) for k in req.environment)
|
||||
if contradicts:
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"id": eid,
|
||||
"kind": e["kind"],
|
||||
"identities": e.get("identities", {}),
|
||||
"maturity": e.get("maturity", "DECLARED"),
|
||||
"status": resolve_status(e),
|
||||
"superseded_by": e.get("superseded_by"),
|
||||
"provides": sorted(provides),
|
||||
"consumers": sorted(_consumers(doc, eid)),
|
||||
"evidence_refs": e.get("evidence_refs", []),
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
# ── fit evaluation ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def evaluate_fit(req: CapabilityRequirement, candidate: dict) -> dict:
|
||||
"""DETERMINISTIC fit of one candidate against a requirement.
|
||||
fit: EXACT | PARTIAL | INCOMPATIBLE | UNKNOWN.
|
||||
Names are ignored; contracts + environment + health decide."""
|
||||
missing_contracts = sorted(set(req.contracts) - set(candidate["provides"]))
|
||||
covered = len(req.contracts) - len(missing_contracts)
|
||||
|
||||
# health: a superseded or stale candidate cannot be reused as-is
|
||||
status = candidate["status"]
|
||||
unhealthy = status in {"SUPERSEDED", "STALE"} or bool(candidate.get("superseded_by"))
|
||||
|
||||
# maturity: DECLARED-only candidates have no usage evidence
|
||||
maturity = _MATURITY_RANK.get(candidate.get("maturity", "DECLARED"), 0)
|
||||
|
||||
if unhealthy:
|
||||
return {
|
||||
"candidate": candidate["id"],
|
||||
"fit": "INCOMPATIBLE",
|
||||
"reason": f"candidate status {status} — not reusable as-is",
|
||||
"missing_contracts": missing_contracts,
|
||||
"covered": covered,
|
||||
}
|
||||
if req.contracts and not candidate["provides"]:
|
||||
# candidate provides nothing verifiable → cannot establish fit
|
||||
return {
|
||||
"candidate": candidate["id"],
|
||||
"fit": "UNKNOWN",
|
||||
"reason": "candidate exposes no contracts to check",
|
||||
"missing_contracts": missing_contracts,
|
||||
"covered": 0,
|
||||
}
|
||||
if not missing_contracts:
|
||||
fit = "EXACT"
|
||||
reason = "all required contracts provided"
|
||||
elif covered > 0:
|
||||
fit = "PARTIAL"
|
||||
reason = f"covers {covered}/{len(req.contracts)} required contracts"
|
||||
else:
|
||||
fit = "INCOMPATIBLE"
|
||||
reason = "provides none of the required contracts"
|
||||
return {
|
||||
"candidate": candidate["id"],
|
||||
"fit": fit,
|
||||
"reason": reason,
|
||||
"missing_contracts": missing_contracts,
|
||||
"covered": covered,
|
||||
"maturity_rank": maturity,
|
||||
"consumers": candidate["consumers"],
|
||||
}
|
||||
|
||||
|
||||
# ── gap / resolution decision ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _adaptation_brittle(doc: dict, candidate_id: str, missing_contracts: list[str]) -> tuple[bool, str]:
|
||||
"""BUILD BUT DON'T BREAK: adaptation is brittle when the contracts the
|
||||
candidate is missing are load-bearing elsewhere (other entities consume
|
||||
or provide them), i.e. adapting means touching a shared surface."""
|
||||
for c in missing_contracts:
|
||||
ce = entity(doc, c)
|
||||
if ce is not None:
|
||||
consumers = _consumers(doc, c)
|
||||
consumers.discard(candidate_id)
|
||||
if consumers:
|
||||
return True, (f"missing contract {c} is load-bearing (consumed by {sorted(consumers)})")
|
||||
for r in relationships(doc, to_id=c):
|
||||
if r["type"] in _PROVIDES and r["from"] != candidate_id:
|
||||
return True, (f"missing contract {c} is already provided by {r['from']} — adapting would fork it")
|
||||
return False, ""
|
||||
|
||||
|
||||
def decide(
|
||||
doc: dict,
|
||||
req: CapabilityRequirement,
|
||||
fits: list[dict],
|
||||
*,
|
||||
external_evidence: list[dict] | None = None,
|
||||
semantic_fits: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Resolution decision from deterministic fits. Evidence-backed:
|
||||
every verdict carries the fits/rationale that produced it."""
|
||||
external_evidence = list(external_evidence or [])
|
||||
semantic_fits = list(semantic_fits or [])
|
||||
exact = [f for f in fits if f["fit"] == "EXACT"]
|
||||
partial = [f for f in fits if f["fit"] == "PARTIAL"]
|
||||
unknown = [f for f in fits if f["fit"] == "UNKNOWN"]
|
||||
incompatible = [f for f in fits if f["fit"] == "INCOMPATIBLE"]
|
||||
|
||||
def _result(resolution, rationale, **kw):
|
||||
base = {
|
||||
"resolution": resolution,
|
||||
"rationale": rationale,
|
||||
"considered": [f["candidate"] for f in fits],
|
||||
"semantic_fits": semantic_fits,
|
||||
}
|
||||
base.update(kw)
|
||||
assert resolution in RESOLUTIONS
|
||||
return base
|
||||
|
||||
# INSUFFICIENT EVIDENCE dominates when fit cannot be established
|
||||
if unknown and not exact and not partial:
|
||||
return _result(
|
||||
"INSUFFICIENT_EVIDENCE",
|
||||
"candidates exist but expose no checkable contracts; no deterministic or recorded semantic evidence",
|
||||
unknown_candidates=[f["candidate"] for f in unknown],
|
||||
)
|
||||
if not fits:
|
||||
# nothing structural at all: external evidence decides the route
|
||||
if external_evidence:
|
||||
return _result(
|
||||
"ACQUIRE_EXTERNAL",
|
||||
"no structural candidates; external evidence suggests acquisition investigation",
|
||||
external=external_evidence,
|
||||
)
|
||||
return _result(
|
||||
"INSUFFICIENT_EVIDENCE",
|
||||
"no candidates discovered and no external evidence — BUILD_NEW not silently chosen (§27 J)",
|
||||
)
|
||||
|
||||
# EXACT: already satisfied vs reuse
|
||||
if exact:
|
||||
best = max(exact, key=lambda f: f.get("maturity_rank", 0))
|
||||
if req.props.get("already_consumed_by") and req.props["already_consumed_by"] in best.get("consumers", []):
|
||||
return _result(
|
||||
"SATISFIED",
|
||||
f"{best['candidate']} exactly matches and the requirement's context already consumes it",
|
||||
selected=best["candidate"],
|
||||
fits=exact,
|
||||
)
|
||||
return _result(
|
||||
"REUSE",
|
||||
f"{best['candidate']} exactly matches all required contracts with health evidence",
|
||||
selected=best["candidate"],
|
||||
fits=exact,
|
||||
)
|
||||
|
||||
# PARTIAL: compose, adapt (clean), or build
|
||||
if partial:
|
||||
# COMPOSITION: union of ≥2 partials covers the requirement
|
||||
if len(partial) >= 2:
|
||||
union: set[str] = set()
|
||||
chosen: list[str] = []
|
||||
for f in sorted(partial, key=lambda x: -x["covered"]):
|
||||
new = set(req.contracts) - union
|
||||
if new & set(req.contracts) and not (set(f["missing_contracts"]) >= set(req.contracts)):
|
||||
add = [c for c in req.contracts if c not in union and c not in f["missing_contracts"]]
|
||||
if add:
|
||||
chosen.append(f["candidate"])
|
||||
union.update(add)
|
||||
if union >= set(req.contracts):
|
||||
break
|
||||
if union >= set(req.contracts):
|
||||
return _result(
|
||||
"COMPOSE",
|
||||
f"composition {chosen} jointly covers all required contracts; none covers alone",
|
||||
components=chosen,
|
||||
permanence="temporary (user-controlled)",
|
||||
)
|
||||
# ADAPT: best partial, if adaptation is clean
|
||||
best = max(partial, key=lambda f: f["covered"])
|
||||
brittle, why = _adaptation_brittle(doc, best["candidate"], best["missing_contracts"])
|
||||
if not brittle:
|
||||
return _result(
|
||||
"ADAPT_EXISTING",
|
||||
f"{best['candidate']} covers {best['covered']}/"
|
||||
f"{len(req.contracts)}; adaptation clean ({best['reason']})",
|
||||
selected=best["candidate"],
|
||||
missing=best["missing_contracts"],
|
||||
obligations={"new_behavior_proof": True, "regression_proof": True},
|
||||
)
|
||||
if external_evidence:
|
||||
return _result(
|
||||
"ACQUIRE_EXTERNAL",
|
||||
f"best partial ({best['candidate']}) cannot be "
|
||||
f"adapted without brittle coupling ({why}); "
|
||||
f"external evidence available",
|
||||
external=external_evidence,
|
||||
)
|
||||
return _result(
|
||||
"BUILD_NEW",
|
||||
f"best partial ({best['candidate']}) would create brittle "
|
||||
f"coupling: {why}; composition insufficient; building is the "
|
||||
f"safer path — REUSE-WHEN-CLEAN, BUILD-WHEN-FORCED-REUSE-"
|
||||
f"WOULD-BRITTLE (§5)",
|
||||
selected=None,
|
||||
rejected_partial=best["candidate"],
|
||||
brittle=why,
|
||||
obligations={"new_behavior_proof": True},
|
||||
)
|
||||
|
||||
# everything incompatible
|
||||
if external_evidence:
|
||||
return _result(
|
||||
"ACQUIRE_EXTERNAL", "all candidates incompatible; external evidence available", external=external_evidence
|
||||
)
|
||||
if incompatible:
|
||||
return _result(
|
||||
"INSUFFICIENT_EVIDENCE",
|
||||
"all candidates incompatible with requirement "
|
||||
"constraints; affirmative gap evidence required "
|
||||
"before BUILD_NEW",
|
||||
incompatible=[f["candidate"] for f in incompatible],
|
||||
)
|
||||
return _result("INSUFFICIENT_EVIDENCE", "no usable fit evidence")
|
||||
|
||||
|
||||
def resolve(
|
||||
doc: dict,
|
||||
req: CapabilityRequirement,
|
||||
*,
|
||||
external_evidence: list[dict] | None = None,
|
||||
semantic_fits: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""One-call surface: discover → fit → decide."""
|
||||
fits = [evaluate_fit(req, c) for c in discover_candidates(doc, req)]
|
||||
return decide(doc, req, fits, external_evidence=external_evidence, semantic_fits=semantic_fits)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""construction_activation.py — the activation dispatcher.
|
||||
|
||||
SCAFFOLD-TRANCHE-0011. §3: NOT another planner/router/engine. Given an
|
||||
evidence-backed ConstructionMode, start/resume the correct Scaffolding
|
||||
lifecycle. ONE activation authority; consumers: Rune native backend path,
|
||||
Construction Interface (MiMo/other builders), manual operator override.
|
||||
|
||||
Mode dispatch (tranche brief §4/§7/§8/§9/§10):
|
||||
|
||||
NOT_CONSTRUCTION → no Scaffolding state touched (§10)
|
||||
LOCAL_CHANGE → bounded governance via the existing project's
|
||||
authority; Construction Contract only if policy
|
||||
requires; no Project Scaffold bootstrap (§7)
|
||||
EXISTING_PROJECT_CHANGE
|
||||
→ attach to the existing project authority (Rune
|
||||
Spine for Rune; bound Project Scaffold otherwise);
|
||||
full proportional lifecycle (§8) — never a new
|
||||
project (§1 existence evidence governs)
|
||||
PROJECT_BUILD → actually bootstrap: provisional Project Scaffold
|
||||
initialized with UNKNOWN fields explicit (§6/§7) —
|
||||
repository: undecided, toolchain: unresolved —
|
||||
progressive refinement, nothing manufactured
|
||||
UNKNOWN → NO activation; return evidence gaps + probe
|
||||
questions (§9)
|
||||
|
||||
Persistence discipline (§21): every state mutation goes through
|
||||
Scaffold.commit() (save + readback verify) BEFORE any accounting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from rune import structural_store as ss
|
||||
from rune import scaffold as sc
|
||||
from rune import construction_mode as cm
|
||||
|
||||
__all__ = ["activate"]
|
||||
|
||||
|
||||
def _scaffold_for(project: str, *, create: bool, parent_scaffold: sc.Scaffold | None = None) -> sc.Scaffold | None:
|
||||
"""Resolve the Project Scaffold store for a project (§1 evidence:
|
||||
existence comes from a bound store on disk, not from the name).
|
||||
Location derives from the PARENT scaffold's store directory — no
|
||||
hardcoded relative path (T11 fix: activation under a tmp/fixture parent
|
||||
must write the fixture root, not the repo's store)."""
|
||||
parent_dir = parent_scaffold.current.parent if parent_scaffold else Path("store/scaffold")
|
||||
base = parent_dir / "projects" / project
|
||||
current = base / "current.json"
|
||||
if current.is_file():
|
||||
return sc.Scaffold(base, project)
|
||||
if create:
|
||||
return sc.Scaffold(base, project)
|
||||
return None
|
||||
|
||||
|
||||
def activate(mode_decision, *, need, scaffold: sc.Scaffold, builder: str = "rune") -> dict:
|
||||
"""Dispatch on the evidence-backed mode; return an activation record
|
||||
(mode, project authority, actions taken, context ref). The NEED must
|
||||
already be classified — UNKNOWN never reaches here without explicit
|
||||
operator override carried in mode_decision."""
|
||||
mode = mode_decision.mode
|
||||
rec: dict = {
|
||||
"mode": mode,
|
||||
"builder": builder,
|
||||
"target_project": mode_decision.target_project,
|
||||
"evidence": list(mode_decision.evidence),
|
||||
}
|
||||
|
||||
if mode == "NOT_CONSTRUCTION":
|
||||
# §10: no Scaffolding state touched
|
||||
rec["activated"] = False
|
||||
rec["reason"] = "non-construction intent — no construction state"
|
||||
return rec
|
||||
|
||||
if mode == "UNKNOWN":
|
||||
# §9: no activation; probe questions surface
|
||||
rec["activated"] = False
|
||||
rec["reason"] = "insufficient evidence — clarification required; no Project Scaffold initialized"
|
||||
rec["probe_questions"] = mode_decision.probe_questions
|
||||
rec["clarification_required"] = True
|
||||
return rec
|
||||
|
||||
if mode == "LOCAL_CHANGE":
|
||||
# §7: governance without ceremony — reuse the existing project's
|
||||
# authority; the lightest contract path (caller decides policy);
|
||||
# NO new Project Scaffold bootstrap
|
||||
rec["activated"] = True
|
||||
rec["lifecycle"] = "bounded"
|
||||
rec["project_authority"] = mode_decision.project_authority or "rune_internal"
|
||||
rec["note"] = "bounded governance via existing authority — no Project Scaffold bootstrap"
|
||||
return rec
|
||||
|
||||
if mode == "EXISTING_PROJECT_CHANGE":
|
||||
# §8: attach to the existing authority — never a new project.
|
||||
# Existence was already proven structurally (T11 §1) before this
|
||||
# mode was selected.
|
||||
rec["activated"] = True
|
||||
rec["lifecycle"] = "proportional"
|
||||
rec["project_authority"] = mode_decision.project_authority or "rune_internal"
|
||||
rec["note"] = (
|
||||
"attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally"
|
||||
)
|
||||
return rec
|
||||
|
||||
if mode == "PROJECT_BUILD":
|
||||
# §6/§7: actually bootstrap. Provisional Project Scaffold with
|
||||
# explicit UNKNOWN fields; identity before repository (§7).
|
||||
project = mode_decision.target_project or need.project
|
||||
if not project:
|
||||
# Rune-originated Needs may carry no project identity (§18):
|
||||
# derive a stable provisional id from the objective — the
|
||||
# bootstrap is provisional and renameable before any repo
|
||||
# exists (§7 identity before repository).
|
||||
import re as _re
|
||||
|
||||
slug = _re.sub(r"[^a-z0-9]+", "-", need.objective.lower()).strip("-")[:48]
|
||||
project = slug or "unnamed-project"
|
||||
ps = _scaffold_for(project, create=True, parent_scaffold=scaffold)
|
||||
assert ps is not None
|
||||
pdoc = ps.load()
|
||||
if ss.entity(pdoc, f"project:{project}") is None:
|
||||
ps.init(purpose=need.objective) # init persists its own doc
|
||||
pdoc = ps.load() # §21: reload after persistence
|
||||
structural = ss.entity(pdoc, f"project:{project}")
|
||||
assert structural is not None
|
||||
# explicit UNKNOWN fields — nothing manufactured (§6/§10)
|
||||
structural.setdefault("ext", {}).update(
|
||||
{
|
||||
"bootstrap": "initialized",
|
||||
"repository": "undecided",
|
||||
"toolchain": "unresolved",
|
||||
"deployment": "unknown",
|
||||
"requirement_gaps": list(mode_decision.probe_questions)
|
||||
or ["target platform unknown", "requirements not yet refined"],
|
||||
"bootstrap_started_at": ss.now(),
|
||||
"origin_need": need.need_id,
|
||||
"origin_builder": builder,
|
||||
}
|
||||
)
|
||||
ps.commit(pdoc, what=f"PROJECT_BUILD bootstrap: {project}")
|
||||
rec.update(
|
||||
{
|
||||
"activated": True,
|
||||
"lifecycle": "project_build",
|
||||
"project": project,
|
||||
"project_scaffold": str(ps.current),
|
||||
"bootstrap": "initialized",
|
||||
"repository": "undecided",
|
||||
"toolchain": "unresolved",
|
||||
"deployment": "unknown",
|
||||
"requirement_gaps": structural["ext"]["requirement_gaps"],
|
||||
"next_action": ("refine requirements; resolve toolchain/repo decisions via evidence (approval-gated)"),
|
||||
}
|
||||
)
|
||||
return rec
|
||||
|
||||
rec["activated"] = False
|
||||
rec["reason"] = f"unhandled mode {mode}"
|
||||
return rec
|
||||
|
||||
|
||||
def dispatch_from_decision(trace: dict, *, need, scaffold: sc.Scaffold, builder: str = "rune") -> dict:
|
||||
"""§15: read the mode from a make_decision trace and dispatch. The
|
||||
shared Construction Context then derives from the SAME canonical state
|
||||
— no duplicate machines."""
|
||||
mode = trace.get("construction_mode", {}).get("mode", "UNKNOWN")
|
||||
return activate(mode, need=need, scaffold=scaffold, builder=builder)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""construction_commands.py — T16 §16J: bounded validated inbound
|
||||
command interface for future Substrate integration.
|
||||
|
||||
The future Substrate ASKS; Scaffolding DECIDES. Every command:
|
||||
|
||||
- carries identity (command_id, correlation_id, construction_id)
|
||||
- is validated by Scaffolding against the existing lifecycle
|
||||
(LEGAL_TRANSITIONS — no new lifecycle invented)
|
||||
- cannot directly mutate storage (commands return validated intents;
|
||||
the Construction Interface executes them through its own governed
|
||||
path — the caller never writes the store)
|
||||
- is idempotent where applicable (repeat delivery of the same
|
||||
command_id returns the same recorded outcome)
|
||||
|
||||
Only commands that fit EXISTING semantics safely are exposed. PAUSE and
|
||||
CANCEL are intentionally NOT exposed: the lifecycle has no paused phase
|
||||
and abandonment needs user provenance — a future Substrate asking for
|
||||
them gets a refusal, not a half-measure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from rune.scaffold import LEGAL_TRANSITIONS
|
||||
|
||||
COMMANDS = (
|
||||
"REQUEST_STATE", "START", "RESUME", "ACKNOWLEDGE_BLOCKER",
|
||||
"PROVIDE_APPROVAL", "PROVIDE_CONTEXT",
|
||||
)
|
||||
|
||||
# Command → the interface operation it asks for (the interface still
|
||||
# decides: validation failures return refused, never raise into storage).
|
||||
_COMMAND_TO_OP = {
|
||||
"REQUEST_STATE": "resume", # read-only state projection
|
||||
"START": "transition", # draft → approved (then building)
|
||||
"RESUME": "resume", # re-read canonical state
|
||||
"ACKNOWLEDGE_BLOCKER": "transition", # blocked → building (retry path)
|
||||
"PROVIDE_APPROVAL": "transition", # draft → approved with provenance
|
||||
"PROVIDE_CONTEXT": "amend_scope", # context arrives as scope amendment
|
||||
}
|
||||
|
||||
# In-memory idempotency ledger: command_id → recorded outcome. The
|
||||
# Scaffolding lifecycle is the authority; this ledger only makes repeat
|
||||
# delivery return the same answer (no double transition).
|
||||
_outcomes: dict[str, dict] = {}
|
||||
|
||||
|
||||
def command(command_type: str, *, construction_id: str,
|
||||
correlation_id: str = "", command_id: str = "",
|
||||
payload: dict | None = None,
|
||||
scaffold=None) -> dict:
|
||||
"""Validate an inbound command. Returns a validated intent envelope:
|
||||
|
||||
{"accepted": True, "op": <interface op>, "args": {...},
|
||||
"command_id": ..., "idempotent_replay": bool}
|
||||
|
||||
or {"accepted": False, "reason": ...}. The caller executes the intent
|
||||
through the Construction Interface — it never touches storage."""
|
||||
payload = payload or {}
|
||||
command_id = command_id or f"cmd-{uuid.uuid4().hex[:8]}"
|
||||
if command_id in _outcomes:
|
||||
prior = dict(_outcomes[command_id])
|
||||
prior["idempotent_replay"] = True
|
||||
return prior
|
||||
if command_type not in COMMANDS:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"unknown command {command_type!r} "
|
||||
f"(supported: {', '.join(COMMANDS)})")
|
||||
if not construction_id:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"construction_id is required")
|
||||
intent = _validate(command_type, construction_id,
|
||||
correlation_id, command_id, payload, scaffold)
|
||||
_outcomes[command_id] = intent
|
||||
return intent
|
||||
|
||||
|
||||
def _refuse(command_id: str, construction_id: str,
|
||||
correlation_id: str, reason: str) -> dict:
|
||||
return {"accepted": False, "reason": reason,
|
||||
"command_id": command_id, "construction_id": construction_id,
|
||||
"correlation_id": correlation_id,
|
||||
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime()),
|
||||
"idempotent_replay": False}
|
||||
|
||||
|
||||
def _validate(command_type: str, construction_id: str,
|
||||
correlation_id: str, command_id: str,
|
||||
payload: dict, scaffold) -> dict:
|
||||
"""Validate against the CURRENT canonical state — the lifecycle
|
||||
decides, not the requester. The scaffold (canonical store handle) is
|
||||
supplied by the serving layer; the client only ever sees the returned
|
||||
envelope, never the store."""
|
||||
if scaffold is None:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"no serving scaffold bound (server-side wiring)")
|
||||
doc = scaffold.load()
|
||||
contract = None
|
||||
for e in doc.get("entities", []):
|
||||
if e.get("id") == f"contract:{construction_id}":
|
||||
contract = e
|
||||
break
|
||||
if contract is None:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"unknown construction {construction_id!r}")
|
||||
phase = contract.get("phase", "")
|
||||
|
||||
if command_type == "REQUEST_STATE":
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"resume", {"contract_id": construction_id})
|
||||
if command_type == "RESUME":
|
||||
if phase in ("closed", "abandoned"):
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"construction is {phase} — resume is invalid")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"resume", {"contract_id": construction_id})
|
||||
if command_type == "START":
|
||||
if phase != "draft":
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"START requires draft phase (current: {phase})")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"transition",
|
||||
{"contract_id": construction_id,
|
||||
"phase": "approved"})
|
||||
if command_type == "PROVIDE_APPROVAL":
|
||||
if phase != "draft":
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"approval applies to draft (current: {phase})")
|
||||
if payload.get("approved_by") != "user":
|
||||
# lifecycle rule: approval provenance must be the user —
|
||||
# Substrate cannot approve on the user's behalf
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"approval provenance must be user "
|
||||
"(Substrate cannot approve for the user)")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"transition",
|
||||
{"contract_id": construction_id,
|
||||
"phase": "approved",
|
||||
"approved_by": "user"})
|
||||
if command_type == "ACKNOWLEDGE_BLOCKER":
|
||||
if phase not in ("failed", "building", "verifying"):
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"no blockable phase (current: {phase})")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"transition",
|
||||
{"contract_id": construction_id,
|
||||
"phase": "building"})
|
||||
if command_type == "PROVIDE_CONTEXT":
|
||||
paths = payload.get("add_paths") or []
|
||||
if not paths:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"PROVIDE_CONTEXT requires add_paths")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"amend_scope",
|
||||
{"contract_id": construction_id,
|
||||
"add_paths": paths,
|
||||
"reason": payload.get("reason", "substrate context"),
|
||||
"authority": "user"
|
||||
if payload.get("approved_by") == "user" else "substrate"})
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"unreachable validator branch")
|
||||
|
||||
|
||||
def _accept(command_id: str, construction_id: str, correlation_id: str,
|
||||
op: str, args: dict) -> dict:
|
||||
return {"accepted": True, "op": op, "args": args,
|
||||
"command_id": command_id, "construction_id": construction_id,
|
||||
"correlation_id": correlation_id,
|
||||
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime()),
|
||||
"idempotent_replay": False}
|
||||
@@ -0,0 +1,937 @@
|
||||
"""construction_decision.py — live Need → Decision → Construction Contract.
|
||||
|
||||
SCAFFOLD-TRANCHE-004: the adoption layer that turns the Tranche 2/3
|
||||
mechanisms into a durable procedure Rune no longer has to remember.
|
||||
|
||||
NEED (references Task/Intent — never duplicates them)
|
||||
↓ normalize_need() missing info is NOT invented
|
||||
↓ discover + fit per authority (deterministic; Spine + Project Scaffold)
|
||||
↓ resolve per authority + merge (capability_fit resolution surface)
|
||||
↓ resolve_toolchain() (existing project toolchain wins)
|
||||
↓ layout obligations (LOCKED authority inherited)
|
||||
↓ approval gate (material changes → user)
|
||||
↓ DecisionTrace (durable, model-independent)
|
||||
↓ generate_contract() (populated from the trace)
|
||||
|
||||
Boundaries (brief §3, §32, §33):
|
||||
- NOT a task engine: Need carries a reference to the existing Task/Intent
|
||||
authority; Scaffolding never becomes the "why" authority.
|
||||
- NOT a workflow engine: one decision function, one trace object.
|
||||
- NOT a research pipeline: insufficient evidence routes to a recorded
|
||||
research path (seam) — the live ADAPT wiring is a recorded route, not
|
||||
an executed pipeline call (honest seam, §6).
|
||||
- NOT a structural authority: it reads Spine/Project Scaffold and
|
||||
WRITES only Construction Contracts + decision traces.
|
||||
- BUILD_NEW is never a fallback (§8): merge precedence and the
|
||||
insufficient-evidence invariant of capability_fit enforce it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune import capability_fit as cf
|
||||
from rune import toolchain as tc
|
||||
from rune.structural_store import relationships
|
||||
|
||||
__all__ = ["Need", "make_decision", "generate_contract", "merge_resolutions"]
|
||||
|
||||
|
||||
# ── Need intake (§4) ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Need:
|
||||
"""Construction-decision input. References — does not copy — the
|
||||
existing Task/Intent authority (`task_ref` is a pointer, not a copy).
|
||||
Carries no task state of its own: it is WHY-adjacent input to Scaffolding,
|
||||
not a backlog entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
need_id: str,
|
||||
objective: str,
|
||||
*,
|
||||
origin: str = "user",
|
||||
project: str = "rune",
|
||||
task_ref: str = "",
|
||||
constraints: dict | None = None,
|
||||
affected_surface: list[str] | None = None,
|
||||
evidence_refs: list[str] | None = None,
|
||||
approval_context: str = "",
|
||||
requested_permanence: str = "",
|
||||
):
|
||||
if not need_id:
|
||||
raise ValueError("need_id required")
|
||||
if not objective:
|
||||
raise ValueError("objective (required effect) required — do not normalize a need we cannot state")
|
||||
self.need_id = need_id
|
||||
self.objective = objective
|
||||
self.origin = origin
|
||||
self.project = project
|
||||
self.task_ref = task_ref
|
||||
self.constraints = dict(constraints or {})
|
||||
self.affected_surface = list(affected_surface or [])
|
||||
self.evidence_refs = list(evidence_refs or [])
|
||||
self.approval_context = approval_context
|
||||
self.requested_permanence = requested_permanence
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"need_id": self.need_id,
|
||||
"objective": self.objective,
|
||||
"origin": self.origin,
|
||||
"project": self.project,
|
||||
"task_ref": self.task_ref,
|
||||
"constraints": dict(self.constraints),
|
||||
"affected_surface": list(self.affected_surface),
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
"approval_context": self.approval_context,
|
||||
"requested_permanence": self.requested_permanence,
|
||||
}
|
||||
|
||||
|
||||
# ── requirement normalization (§5) ─────────────────────────────────────────
|
||||
|
||||
|
||||
def normalize_need(need: Need) -> tuple[cf.CapabilityRequirement, list[str]]:
|
||||
"""Derive the CapabilityRequirement WITHOUT inventing facts. Contracts
|
||||
may come from explicit constraints (`contracts` key) or from contract-
|
||||
shaped entries on the affected surface (`contract:x` ids). Everything
|
||||
not determinable lands in `missing` — the caller records it and the
|
||||
resolution degrades to INSUFFICIENT_EVIDENCE rather than guessing."""
|
||||
contracts = sorted(need.constraints.get("contracts", []))
|
||||
contracts += sorted(a for a in need.affected_surface if a.startswith("contract:"))
|
||||
missing = []
|
||||
if need.constraints.get("environment"):
|
||||
pass # carried through
|
||||
else:
|
||||
missing.append("environment")
|
||||
if not need.evidence_refs:
|
||||
missing.append("evidence_refs")
|
||||
req = cf.CapabilityRequirement(
|
||||
need.objective,
|
||||
contracts=contracts,
|
||||
environment=need.constraints.get("environment", {}),
|
||||
target_project=need.project,
|
||||
source_need=need.need_id,
|
||||
task_ref=need.task_ref,
|
||||
requested_permanence=need.requested_permanence,
|
||||
**{k: v for k, v in need.constraints.items() if k not in ("contracts", "environment")},
|
||||
)
|
||||
return req, missing
|
||||
|
||||
|
||||
# ── affected-surface classification + resolution (Tranche 5 §3-5) ──────────
|
||||
|
||||
_SURFACE_UI = "ui:"
|
||||
_SURFACE_REGION = "region:"
|
||||
_SURFACE_CONTRACT = "contract:"
|
||||
_SURFACE_MODULE = "module:"
|
||||
|
||||
|
||||
def classify_surface(surface: str) -> str:
|
||||
"""Classify one affected-surface entry (open-world; Tranche 5 §3).
|
||||
Kinds: structural_entity | source_path | workflow_config | ui_component |
|
||||
layout_region | unresolved. Never drops an entry — unresolved is a
|
||||
first-class kind."""
|
||||
if surface.startswith((_SURFACE_UI,)):
|
||||
return "ui_component"
|
||||
if surface.startswith((_SURFACE_REGION,)):
|
||||
return "layout_region"
|
||||
if surface.startswith(
|
||||
(
|
||||
_SURFACE_CONTRACT,
|
||||
_SURFACE_MODULE,
|
||||
_SURFACE_CAPABILITY,
|
||||
)
|
||||
):
|
||||
return "structural_entity"
|
||||
if surface.endswith(".yml") or surface.endswith(".yaml") or surface.endswith(".json") or surface.endswith(".toml"):
|
||||
return "workflow_config"
|
||||
if (
|
||||
surface.endswith((".py", ".js", ".ts", ".jsx", ".tsx", ".rs",
|
||||
".md", ".go", ".java", ".c", ".cpp", ".h",
|
||||
".css", ".html", ".yaml", ".yml",
|
||||
".mjs", ".cjs", ".mts", ".cts"))
|
||||
or "/" in surface
|
||||
):
|
||||
return "source_path"
|
||||
return "unresolved"
|
||||
|
||||
|
||||
_SURFACE_CAPABILITY = "capability:"
|
||||
|
||||
|
||||
def resolve_surface(surface: str, kind: str, authorities: dict[str, dict]) -> dict:
|
||||
"""Deterministically resolve one surface against the structural
|
||||
authorities (Tranche 5 §5). No Memory, no LLM archaeology: identity
|
||||
lookups and recorded path->module mappings only.
|
||||
|
||||
Returns {surface, kind, resolved: [...], unresolved_reason: ""}.
|
||||
resolved entries: {entity_id, authority, via} where via records HOW the
|
||||
mapping was established (identity | path_attribute | provides)."""
|
||||
out = {"surface": surface, "kind": kind, "resolved": [], "unresolved_reason": ""}
|
||||
if kind == "structural_entity":
|
||||
for name, doc in authorities.items():
|
||||
e = ss_entity(doc, surface)
|
||||
if e is not None:
|
||||
out["resolved"].append({"entity_id": surface, "authority": name, "via": "identity"})
|
||||
break
|
||||
if not out["resolved"]:
|
||||
out["unresolved_reason"] = "entity id not found in any authority"
|
||||
elif kind == "source_path":
|
||||
# path -> module via recorded identities.path in any authority
|
||||
for name, doc in authorities.items():
|
||||
for e in doc["entities"]:
|
||||
p = e.get("identities", {}).get("path", "")
|
||||
if p and (p == surface or p.endswith("/" + surface) or surface.endswith(p)):
|
||||
out["resolved"].append({"entity_id": e["id"], "authority": name, "via": "path_attribute"})
|
||||
if not out["resolved"]:
|
||||
out["unresolved_reason"] = "no authority records this path as an entity (path remains file-scope only)"
|
||||
elif kind == "ui_component":
|
||||
for name, doc in authorities.items():
|
||||
cid = surface if surface.startswith("ui:") else f"ui:{surface}"
|
||||
e = ss_entity(doc, f"layout:{cid}")
|
||||
if e is not None:
|
||||
out["resolved"].append({"entity_id": f"layout:{cid}", "authority": name, "via": "layout_contract"})
|
||||
if not out["resolved"]:
|
||||
out["unresolved_reason"] = "no Layout Contract bound for component"
|
||||
elif kind == "layout_region":
|
||||
for name, doc in authorities.items():
|
||||
e = ss_entity(doc, surface if surface.startswith("region:") else f"region:{surface}")
|
||||
if e is not None:
|
||||
out["resolved"].append({"entity_id": e["id"], "authority": name, "via": "identity"})
|
||||
if not out["resolved"]:
|
||||
out["unresolved_reason"] = "region not defined in any authority"
|
||||
else:
|
||||
out["unresolved_reason"] = f"kind {kind} has no deterministic resolver"
|
||||
return out
|
||||
|
||||
|
||||
def ss_entity(doc: dict, eid: str):
|
||||
from rune.structural_store import entity as _e
|
||||
|
||||
return _e(doc, eid)
|
||||
|
||||
|
||||
def implemented_contracts(doc: dict, entity_id: str) -> list[str]:
|
||||
"""Contracts an entity implements/exposes/provides (structural fact)."""
|
||||
out = []
|
||||
for r in relationships(doc, from_id=entity_id):
|
||||
if r["type"] in {"implements", "exposes", "provides", "serves"}:
|
||||
out.append(r["to"])
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def resolve_affected_surface(need: Need, authorities: dict[str, dict]) -> dict:
|
||||
"""Classify + resolve the WHOLE affected surface (Tranche 5 §3-5, §13).
|
||||
Produces the propagated representation the generated contract carries:
|
||||
affected_existing_entities structural ids (resolved)
|
||||
affected_paths file-scope entries (mechanical scope)
|
||||
affected_ui_surfaces ui components / regions found
|
||||
unresolved_surfaces retained, never dropped
|
||||
File scope != structural impact: a path stays a path for scope checking
|
||||
even when it also resolves to a module entity (no authority duplication)."""
|
||||
result = {
|
||||
"affected_existing_entities": [],
|
||||
"affected_paths": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"affected_regions": [],
|
||||
"workflow_config_surfaces": [],
|
||||
"unresolved_surfaces": [],
|
||||
"resolutions": [],
|
||||
}
|
||||
for surface in need.affected_surface:
|
||||
kind = classify_surface(surface)
|
||||
res = resolve_surface(surface, kind, authorities)
|
||||
result["resolutions"].append(res)
|
||||
if kind == "source_path":
|
||||
result["affected_paths"].append(surface)
|
||||
elif kind == "workflow_config":
|
||||
result["workflow_config_surfaces"].append(surface)
|
||||
elif kind == "ui_component":
|
||||
result["affected_ui_surfaces"].append(surface)
|
||||
elif kind == "layout_region":
|
||||
result["affected_regions"].append(surface)
|
||||
for r in res["resolved"]:
|
||||
eid = r["entity_id"]
|
||||
if eid not in result["affected_existing_entities"] and not eid.startswith("layout:"):
|
||||
result["affected_existing_entities"].append(eid)
|
||||
if res["unresolved_reason"] and not res["resolved"]:
|
||||
result["unresolved_surfaces"].append({"surface": surface, "kind": kind, "reason": res["unresolved_reason"]})
|
||||
for k in (
|
||||
"affected_existing_entities",
|
||||
"affected_paths",
|
||||
"affected_ui_surfaces",
|
||||
"affected_regions",
|
||||
"workflow_config_surfaces",
|
||||
):
|
||||
result[k] = sorted(set(result[k]))
|
||||
|
||||
# §9/§17 DERIVED EXPECTATION (structural consequence, not invention): a
|
||||
# Rune test path (tests/test_<name>.py) affected by the work implies the
|
||||
# "test:<name>" surface participates in the build. Derived only when not
|
||||
# already an existing entity nor explicitly declared; provenance records
|
||||
# the derivation for closure accounting.
|
||||
derived = []
|
||||
for surface in need.affected_surface:
|
||||
if surface.startswith("tests/test_") and surface.endswith(".py"):
|
||||
stem = surface[len("tests/test_") : -len(".py")]
|
||||
test_entity = f"test:{stem}"
|
||||
exists = any(ss_entity(doc, test_entity) is not None for doc in authorities.values())
|
||||
if test_entity not in need.constraints.get("expected_new_entities", []) and not exists:
|
||||
derived.append(
|
||||
{
|
||||
"entity": test_entity,
|
||||
"derived_from": surface,
|
||||
"rule": "Rune test-path convention: tests/test_<name>.py implies "
|
||||
"the test:<name> surface participates in the build",
|
||||
"evidence": f"affected_surface entry {surface}",
|
||||
}
|
||||
)
|
||||
result["derived_expected_new"] = derived
|
||||
return result
|
||||
|
||||
|
||||
# ── requirement completeness gate (Tranche 5 §6-9) ──────────────────────────
|
||||
|
||||
COMPLETE_ENOUGH = "COMPLETE_ENOUGH"
|
||||
MATERIAL_GAP = "MATERIAL_GAP"
|
||||
COMPLETENESS_UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
def check_completeness(
|
||||
req: cf.CapabilityRequirement, need: Need, authorities: dict[str, dict], surface_result: dict
|
||||
) -> dict:
|
||||
"""Bounded, structure-grounded completeness check run BEFORE fit.
|
||||
|
||||
Rule (deterministic, no model vibes): every affected-surface entry that
|
||||
resolves to a structural entity contributes that entity's IMPLEMENTED
|
||||
contracts to the requirement's material scope. If an affected entity
|
||||
implements contracts NOT represented in the normalized requirement, the
|
||||
requirement is materially incomplete — the need touches a component
|
||||
whose obligations it has not stated. That is exactly the deploy-incident
|
||||
shape: the affected promotion implementation carried an unrepresented
|
||||
ordering contract while the requirement named only qualification.
|
||||
|
||||
Verdicts (Tranche 5 §8):
|
||||
COMPLETE_ENOUGH all affected entities' contracts are represented
|
||||
(or none implement contracts) — fit may proceed
|
||||
MATERIAL_GAP uncovered implemented contracts exist on affected
|
||||
entities — named with provenance, routed to probe
|
||||
UNKNOWN no surface resolved — completeness undeterminable;
|
||||
never silently COMPLETE (§8)
|
||||
|
||||
Derived requirements (§9) are structural consequences, not inventions:
|
||||
each uncovered contract carries derived_from (the entity that
|
||||
implements it) + evidence (the implements relationship) + rule.
|
||||
"""
|
||||
gaps = []
|
||||
resolved_any = False
|
||||
for res in surface_result["resolutions"]:
|
||||
for r in res["resolved"]:
|
||||
resolved_any = True
|
||||
for name, doc in authorities.items():
|
||||
e = ss_entity(doc, r["entity_id"])
|
||||
if e is None:
|
||||
continue
|
||||
for contract in implemented_contracts(doc, r["entity_id"]):
|
||||
if contract not in req.contracts:
|
||||
gaps.append(
|
||||
{
|
||||
"missing_contract": contract,
|
||||
"derived_from": r["entity_id"],
|
||||
"surface": res["surface"],
|
||||
"authority": name,
|
||||
"rule": "affected-surface entity implements a "
|
||||
"contract not represented in the "
|
||||
"normalized requirement",
|
||||
"evidence": f"{r['entity_id']} implements {contract} (authority: {name})",
|
||||
}
|
||||
)
|
||||
break # entity found in one authority is enough
|
||||
if gaps:
|
||||
# dedupe by (missing_contract, derived_from)
|
||||
seen = set()
|
||||
unique = []
|
||||
for g in gaps:
|
||||
key = (g["missing_contract"], g["derived_from"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(g)
|
||||
return {
|
||||
"verdict": MATERIAL_GAP,
|
||||
"gaps": sorted(unique, key=lambda g: (g["missing_contract"], g["derived_from"])),
|
||||
"probe_questions": [
|
||||
f"does the need's stated failure implicate {g['derived_from']}'s {g['missing_contract']} obligation?"
|
||||
for g in unique[:5]
|
||||
],
|
||||
"note": "MATERIAL_GAP: affected surface carries obligations "
|
||||
"the requirement does not represent — refine before "
|
||||
"trusting an apparently-complete fit",
|
||||
}
|
||||
if resolved_any:
|
||||
return {
|
||||
"verdict": COMPLETE_ENOUGH,
|
||||
"gaps": [],
|
||||
"probe_questions": [],
|
||||
"note": "all resolved affected entities' implemented contracts are represented in the requirement",
|
||||
}
|
||||
return {
|
||||
"verdict": COMPLETENESS_UNKNOWN,
|
||||
"gaps": [],
|
||||
"probe_questions": [f"which structural entities does '{s}' map to?" for s in need.affected_surface[:5]],
|
||||
"note": "no affected surface resolved — completeness undeterminable; UNKNOWN never silently becomes COMPLETE",
|
||||
}
|
||||
|
||||
|
||||
def record_proposed_requirement(
|
||||
contract_id: str, *, provider: str, model: str, evidence: list[str], confidence: float
|
||||
) -> dict:
|
||||
"""Record a MODEL-PROPOSED requirement addition (Tranche 5 §12). It is
|
||||
evidence with a verification state — never structural authority by
|
||||
itself. It becomes accepted only via existing authority, research
|
||||
evidence, implementation evidence, or explicit user decision; until
|
||||
then normalize_need excludes it from the requirement's contract set."""
|
||||
if not provider or not model:
|
||||
raise ValueError("model-proposed requirement requires provider+model")
|
||||
if not 0.0 <= confidence <= 1.0:
|
||||
raise ValueError("confidence must be in [0,1]")
|
||||
return {
|
||||
"kind": "model_proposed_requirement",
|
||||
"contract": contract_id,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"evidence": sorted(evidence),
|
||||
"confidence": confidence,
|
||||
"verification_state": "PENDING",
|
||||
}
|
||||
|
||||
|
||||
# ── research routing seam (§6 — honest: recorded route, not execution) ─────
|
||||
|
||||
|
||||
def research_route(req: cf.CapabilityRequirement, decision: dict) -> dict:
|
||||
"""Record which evidence path the decision needs. This is a SEAM: the
|
||||
live ADAPT interface is not yet wired for direct invocation, so the
|
||||
route is recorded on the trace for the orchestrating session to act on.
|
||||
NOT claimed as executed research."""
|
||||
if decision["resolution"] == "INSUFFICIENT_EVIDENCE":
|
||||
return {
|
||||
"route": "TARGETED_EVIDENCE_PROBE",
|
||||
"question": f"what satisfies: {req.objective[:120]}",
|
||||
"status": "RECORDED_NOT_EXECUTED",
|
||||
}
|
||||
if decision["resolution"] == "ACQUIRE_EXTERNAL":
|
||||
return {
|
||||
"route": "FULL_RESEARCH_PIPELINE",
|
||||
"question": f"external acquisition options for: {req.objective[:120]}",
|
||||
"status": "RECORDED_NOT_EXECUTED",
|
||||
}
|
||||
return {"route": "NONE", "status": "LOCAL_SOLVE"}
|
||||
|
||||
|
||||
# ── resolution merge across authorities ─────────────────────────────────────
|
||||
|
||||
_PRECEDENCE = [
|
||||
"SATISFIED",
|
||||
"REUSE",
|
||||
"COMPOSE",
|
||||
"ADAPT_EXISTING",
|
||||
"CONFIGURE",
|
||||
"ACQUIRE_EXTERNAL",
|
||||
"INSUFFICIENT_EVIDENCE",
|
||||
"BUILD_NEW",
|
||||
]
|
||||
|
||||
|
||||
def merge_resolutions(results: list[dict]) -> dict:
|
||||
"""Merge per-authority resolution results. Precedence: a concrete
|
||||
reuse/compose/adapt verdict from any authority wins over
|
||||
INSUFFICIENT_EVIDENCE from another; BUILD_NEW can only result when NO
|
||||
authority produced a better verdict AND positive gap evidence exists —
|
||||
it is never a fallback across merges (§8)."""
|
||||
if not results:
|
||||
return {"resolution": "INSUFFICIENT_EVIDENCE", "rationale": "no authority consulted"}
|
||||
best = min(results, key=lambda r: _PRECEDENCE.index(r["resolution"]))
|
||||
merged = dict(best)
|
||||
merged["per_authority"] = [{"authority": r.get("authority", ""), "resolution": r["resolution"]} for r in results]
|
||||
if best["resolution"] == "BUILD_NEW" and len(results) > 1:
|
||||
# only honest if every authority independently agrees
|
||||
if any(r["resolution"] != "BUILD_NEW" for r in results):
|
||||
weaker = [r for r in results if r["resolution"] == "INSUFFICIENT_EVIDENCE"]
|
||||
if weaker:
|
||||
merged = dict(weaker[0])
|
||||
merged["per_authority"] = [
|
||||
{"authority": r.get("authority", ""), "resolution": r["resolution"]} for r in results
|
||||
]
|
||||
merged["rationale"] = (
|
||||
"BUILD_NEW rejected: at least one authority returned "
|
||||
"INSUFFICIENT_EVIDENCE — positive gap evidence required "
|
||||
"from all consulted authorities (§8)"
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
# ── user-approval gate (§13) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def needs_user_approval(
|
||||
resolution: str, toolchain_decision: tc.DecisionRecord, layout_obligations: list[dict], need: Need
|
||||
) -> dict:
|
||||
"""Classify whether decision→contract requires explicit user approval.
|
||||
Small bounded work inside an already-approved contract does NOT re-ask
|
||||
(need.approval_context covers it)."""
|
||||
reasons = []
|
||||
if toolchain_decision.user_approval_required:
|
||||
reasons.append("toolchain change (CHANGE_MAJOR)")
|
||||
if resolution == "BUILD_NEW" and need.requested_permanence != "temporary":
|
||||
reasons.append("new capability construction")
|
||||
if any(o["lock"] for o in layout_obligations):
|
||||
reasons.append("USER-LOCKED layout surface affected — placement changes require user authorization")
|
||||
if need.requested_permanence == "permanent":
|
||||
reasons.append("permanent promotion requested (user-controlled)")
|
||||
if need.constraints.get("new_repository") or need.constraints.get("new_application"):
|
||||
reasons.append("new repository/application")
|
||||
covered = bool(need.approval_context) and not reasons
|
||||
if reasons and need.approval_context and need.constraints.get("approval_covers") == "all":
|
||||
covered = True
|
||||
reasons = [f"{r} — covered by: {need.approval_context}" for r in reasons]
|
||||
return {
|
||||
"required": bool(reasons) and not covered,
|
||||
"reasons": reasons,
|
||||
"covered_by": need.approval_context if covered else "",
|
||||
}
|
||||
|
||||
|
||||
# ── the live decision path ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _project_exists_evidence(project: str | None, authorities: dict[str, dict]) -> tuple[bool | None, list[str]]:
|
||||
"""T11 §1: project existence from STRUCTURAL EVIDENCE only.
|
||||
|
||||
Returns (exists, evidence) where exists is:
|
||||
True — a bound Project Scaffold or Rune Spine carries the project
|
||||
False — the project name is known but NO structural evidence for it
|
||||
None — project identity missing/unresolvable (UNKNOWN; classifier
|
||||
must not manufacture existence)
|
||||
|
||||
Rune itself exists (Rune Spine is its authority). Any other name must
|
||||
appear as a project entity in a consulted authority; a bare string is
|
||||
never proof (held-out: arbitrary "foo" → False, not silently True)."""
|
||||
evidence: list[str] = []
|
||||
if not project:
|
||||
return None, ["no project identity supplied — existence UNKNOWN"]
|
||||
if project in ("rune", "issue161-harness"):
|
||||
evidence.append("Rune Spine is the Rune project authority (rune_internal mode)")
|
||||
return True, evidence
|
||||
for name, doc in authorities.items():
|
||||
for e in doc.get("entities", []):
|
||||
if e.get("kind") == "project" and e.get("id") == f"project:{project}":
|
||||
evidence.append(f"bound Project Scaffold entity project:{project} in {name}")
|
||||
return True, evidence
|
||||
evidence.append(f"no structural evidence for project '{project}' in consulted authorities")
|
||||
return False, evidence
|
||||
|
||||
|
||||
def make_decision(
|
||||
need: Need,
|
||||
*,
|
||||
authorities: dict[str, dict],
|
||||
toolchain_constraints: tc.ToolchainConstraints | None = None,
|
||||
project_toolchain: dict | None = None,
|
||||
prior_iterations: list[dict] | None = None,
|
||||
extra_patterns: list | None = None,
|
||||
) -> dict:
|
||||
"""Run NEED → decision trace. `authorities` maps authority name → store
|
||||
doc (e.g. {"rune_spine": spine_doc, "project_scaffold": scaffold_doc}).
|
||||
`project_toolchain` seeds the Toolchain Resolver's existing-project
|
||||
constraint (existing toolchain wins — §12).
|
||||
`prior_iterations` (Tranche 5 §11): earlier decision iterations being
|
||||
refined — retained in the trace with why-it-changed provenance; the
|
||||
final resolution stays singular.
|
||||
|
||||
Order of operations (Tranche 5 §6): affected-surface resolution →
|
||||
REQUIREMENT COMPLETENESS GATE → discovery/fit → merge. A MATERIAL_GAP
|
||||
downgrades premature concrete verdicts (REUSE/SATISFIED/COMPOSE/
|
||||
ADAPT_EXISTING) to INSUFFICIENT_EVIDENCE with the gap recorded — the
|
||||
fit engine was right GIVEN its requirement; the requirement was too
|
||||
narrow."""
|
||||
req, missing = normalize_need(need)
|
||||
trace: dict = {
|
||||
"need": need.to_dict(),
|
||||
"requirement": req.to_dict(),
|
||||
"normalization_missing": missing,
|
||||
"candidates": [],
|
||||
"fits": [],
|
||||
"per_authority_results": [],
|
||||
"layout_obligations": [],
|
||||
"research": {},
|
||||
"toolchain": {},
|
||||
"approval": {},
|
||||
"resolution": "",
|
||||
"rationale": "",
|
||||
"iterations": list(prior_iterations or []),
|
||||
}
|
||||
|
||||
# affected-surface classification + resolution (Tranche 5 §3-5)
|
||||
surface_result = resolve_affected_surface(need, authorities)
|
||||
trace["affected_surfaces"] = surface_result
|
||||
|
||||
# CONTEXTUAL CONSTRUCTION MODE (Tranche 10 + T11 §1): typed mode
|
||||
# classification with evidence — ONE canonical home (construction_mode).
|
||||
# §1 CORRECTION: project existence derives from STRUCTURAL EVIDENCE
|
||||
# (bound Project Scaffold / Rune Spine), never from a non-"rune" string.
|
||||
from rune.construction_mode import classify_mode
|
||||
|
||||
existence, existence_evidence = _project_exists_evidence(need.project, authorities)
|
||||
mode_decision = classify_mode(
|
||||
need.objective, target_project=need.project, project_exists=existence, affected_surface=need.affected_surface
|
||||
)
|
||||
mode_decision.evidence.extend(existence_evidence)
|
||||
trace["construction_mode"] = mode_decision.to_dict()
|
||||
trace["project_existence"] = {"project": need.project, "exists": existence, "evidence": existence_evidence}
|
||||
|
||||
# REQUIREMENT COMPLETENESS GATE (Tranche 5 §6-8) — before fit
|
||||
completeness = check_completeness(req, need, authorities, surface_result)
|
||||
trace["completeness"] = completeness
|
||||
|
||||
# live discovery + fit + resolution per authority (§7-8)
|
||||
results = []
|
||||
all_fits = []
|
||||
for name, doc in authorities.items():
|
||||
candidates = cf.discover_candidates(doc, req)
|
||||
fits = [cf.evaluate_fit(req, c) for c in candidates]
|
||||
for f, c in zip(fits, candidates):
|
||||
f["authority"] = name
|
||||
c["authority"] = name
|
||||
all_fits.extend(fits)
|
||||
trace["candidates"].extend(
|
||||
{k: c[k] for k in ("id", "kind", "maturity", "status", "authority", "provides", "consumers")}
|
||||
for c in candidates
|
||||
)
|
||||
res = cf.decide(doc, req, fits)
|
||||
res["authority"] = name
|
||||
results.append(res)
|
||||
trace["fits"] = all_fits
|
||||
if results:
|
||||
merged = merge_resolutions(results)
|
||||
else:
|
||||
merged = {"resolution": "INSUFFICIENT_EVIDENCE", "rationale": "no authorities provided"}
|
||||
if missing and merged["resolution"] in ("BUILD_NEW",):
|
||||
# normalization gaps block BUILD_NEW too (§5: do not invent)
|
||||
merged = {
|
||||
"resolution": "INSUFFICIENT_EVIDENCE",
|
||||
"rationale": f"requirement normalization incomplete: {missing}; refusing to build on guesses",
|
||||
}
|
||||
if completeness["verdict"] == MATERIAL_GAP and merged["resolution"] in (
|
||||
"SATISFIED",
|
||||
"REUSE",
|
||||
"COMPOSE",
|
||||
"ADAPT_EXISTING",
|
||||
"CONFIGURE",
|
||||
):
|
||||
# PREMATURE-FIT GUARD (Tranche 5 §6-7): the fit engine was correct
|
||||
# GIVEN its requirement — but the requirement is materially
|
||||
# incomplete (affected surface carries unrepresented obligations).
|
||||
# A concrete verdict on an incomplete requirement is not trustworthy.
|
||||
merged = {
|
||||
"resolution": "INSUFFICIENT_EVIDENCE",
|
||||
"rationale": (
|
||||
"premature fit blocked: requirement is materially incomplete "
|
||||
f"({len(completeness['gaps'])} unrepresented obligation(s) on "
|
||||
"affected surfaces); refine the requirement before trusting "
|
||||
"an apparently-complete fit"
|
||||
),
|
||||
"premature_fit_blocked": {
|
||||
"would_have_been": merged["resolution"],
|
||||
"gaps": completeness["gaps"],
|
||||
},
|
||||
}
|
||||
trace["resolution"] = merged["resolution"]
|
||||
trace["rationale"] = merged.get("rationale", "")
|
||||
if missing and trace["resolution"] == "INSUFFICIENT_EVIDENCE":
|
||||
trace["rationale"] += f"; normalization gaps recorded, not invented: {missing}"
|
||||
trace["per_authority_results"] = merged.get("per_authority", results)
|
||||
trace["_merged"] = merged
|
||||
|
||||
# toolchain (§12): existing project toolchain wins
|
||||
constraints = toolchain_constraints or tc.ToolchainConstraints(**(project_toolchain or {}))
|
||||
tdec = tc.resolve_toolchain(constraints)
|
||||
trace["toolchain"] = tdec.to_dict()
|
||||
|
||||
# layout obligations (§19-20): affected surface + candidate impls
|
||||
affected = list(need.affected_surface)
|
||||
for c in trace["candidates"]:
|
||||
affected.append(c["id"])
|
||||
lay = []
|
||||
for name, doc in authorities.items():
|
||||
lay.extend(layout_obligations_for(doc, affected))
|
||||
trace["layout_obligations"] = lay
|
||||
|
||||
# research routing (§6, honest seam)
|
||||
trace["research"] = research_route(req, merged)
|
||||
|
||||
# PATTERN SELECTION (Tranche 6 §4-7): evidence-backed applicability over
|
||||
# the builtin registry + caller-supplied patterns; obligations filtered
|
||||
# per project evidence; conflicts surfaced, never silently resolved.
|
||||
# Project evidence > pattern assumption (§3): a pattern that contradicts
|
||||
# recorded project exclusions becomes DOES_NOT_APPLY.
|
||||
from rune.construction_pattern import ( # noqa: E402 — local: pattern layer
|
||||
combine_pattern_obligations,
|
||||
derive_pattern_obligations,
|
||||
evaluate_applicability,
|
||||
)
|
||||
from rune.construction_patterns_builtin import BUILTIN_PATTERNS # noqa: E402
|
||||
|
||||
pattern_candidates = list(BUILTIN_PATTERNS) + list(extra_patterns or [])
|
||||
patterns_considered = []
|
||||
derivations = []
|
||||
for pattern in pattern_candidates:
|
||||
app = evaluate_applicability(pattern, need, surface_result, authorities)
|
||||
patterns_considered.append(app)
|
||||
# APPLIES and PARTIAL both reach obligation derivation: applicability
|
||||
# gates whether the pattern is CONSULTED; the per-obligation `when`
|
||||
# conditions do the precision filtering (Tranche 6 §7). The
|
||||
# applicability verdict is recorded on every derived obligation so
|
||||
# weaker evidence stays visible.
|
||||
if app["verdict"] in ("APPLIES", "PARTIAL"):
|
||||
d = derive_pattern_obligations(
|
||||
pattern,
|
||||
need,
|
||||
surface_result,
|
||||
authorities,
|
||||
project_exceptions=need.constraints.get("pattern_exceptions", []),
|
||||
)
|
||||
for entry in d["accepted"] + d["rejected"] + d["unresolved"]:
|
||||
entry["pattern_applicability"] = app["verdict"]
|
||||
derivations.append(d)
|
||||
combined = combine_pattern_obligations(derivations) if derivations else {"obligations": [], "conflicts": []}
|
||||
trace["patterns_considered"] = patterns_considered
|
||||
trace["pattern_obligations"] = combined
|
||||
|
||||
# approval gate (§13)
|
||||
trace["approval"] = needs_user_approval(merged["resolution"], tdec, lay, need)
|
||||
trace["obligations"] = obligations_from_trace(trace, req)
|
||||
# pattern-derived obligations join the contract's verification set (§19):
|
||||
# provenance stays PATTERN (distinct from STRUCTURAL derivations in
|
||||
# obligations_from_trace — §8 provenance separation)
|
||||
trace["obligations"]["pattern"] = combined["obligations"]
|
||||
trace["obligations"]["pattern_conflicts"] = combined["conflicts"]
|
||||
for po in combined["obligations"]:
|
||||
trace["obligations"]["verification"].append(f"[pattern:{po['pattern']}@r{po['revision']}] {po['obligation']}")
|
||||
return trace
|
||||
|
||||
|
||||
def layout_obligations_for(doc: dict, affected: list[str]) -> list[dict]:
|
||||
"""Layout obligations from a store doc without a Scaffold instance
|
||||
(delegates to the same authority logic via a lightweight query)."""
|
||||
obligations = []
|
||||
affected_set = set(affected)
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "layout_contract" or e.get("status") == "RETIRED":
|
||||
continue
|
||||
ident = e["identities"]
|
||||
comp, region, impl = (ident.get("component", ""), ident.get("region", ""), ident.get("implementation", ""))
|
||||
hit = (
|
||||
comp in affected
|
||||
or f"region:{region}" in affected
|
||||
or (impl and any(a in impl for a in affected if a.endswith(".py")))
|
||||
)
|
||||
if not hit:
|
||||
continue
|
||||
locked = bool(ident.get("lock"))
|
||||
obligations.append(
|
||||
{
|
||||
"component": comp,
|
||||
"region": region,
|
||||
"lock": locked,
|
||||
"provenance": ident.get("provenance", "scaffold"),
|
||||
"obligation": (
|
||||
"USER-LOCKED: preserve user placement in region "
|
||||
"'%s'; changes require explicit user authorization" % region
|
||||
)
|
||||
if locked
|
||||
else (
|
||||
"preserve/verify %s placement in region '%s'; movable "
|
||||
"only via authorized layout amendment" % (comp, region)
|
||||
),
|
||||
"verification": ("layout_contract_check must PASS; USER-LOCKED placement unchanged")
|
||||
if locked
|
||||
else "layout_contract_check must PASS",
|
||||
"status": e.get("status", "ACTIVE"),
|
||||
}
|
||||
)
|
||||
return obligations
|
||||
|
||||
|
||||
def obligations_from_trace(trace: dict, req: cf.CapabilityRequirement) -> dict:
|
||||
"""BUILD-BUT-DON'T-BREAK (§9) + composition/acquire obligations (§10-11)
|
||||
derived automatically from the trace — the builder does not memorize the
|
||||
rule; the path produces it."""
|
||||
resolution = trace["resolution"]
|
||||
obligations: dict = {
|
||||
"new_behavior": [],
|
||||
"regression": [],
|
||||
"composition": [],
|
||||
"acquisition": [],
|
||||
"layout": trace["layout_obligations"],
|
||||
"verification": [],
|
||||
}
|
||||
merged = trace.get("_merged", {})
|
||||
selected = merged.get("selected") or merged.get("components") or []
|
||||
if isinstance(selected, str):
|
||||
selected = [selected]
|
||||
if resolution in ("ADAPT_EXISTING", "REUSE", "SATISFIED") and selected:
|
||||
# regression obligations from existing consumers + contracts (§9)
|
||||
for c in trace["candidates"]:
|
||||
if c["id"] in selected and c.get("consumers"):
|
||||
obligations["regression"].append(
|
||||
f"preserve existing behavior of {c['id']} for consumers: {', '.join(c['consumers'])}"
|
||||
)
|
||||
obligations["verification"].append("new behavior proven by tests before closure")
|
||||
if resolution == "ADAPT_EXISTING":
|
||||
obligations["verification"].append("regression proof: original capability's consumers still work")
|
||||
if resolution == "COMPOSE":
|
||||
obligations["composition"] = [
|
||||
{
|
||||
"components": merged.get("components", selected),
|
||||
"joining_contracts": req.contracts,
|
||||
"permanence": merged.get("permanence", "temporary (user-controlled)"),
|
||||
}
|
||||
]
|
||||
obligations["verification"].append("composition boundary behavior verified end-to-end")
|
||||
if resolution == "ACQUIRE_EXTERNAL":
|
||||
obligations["acquisition"] = merged.get("external", [])
|
||||
obligations["verification"].append("external acquisition requires research/approval step before install")
|
||||
if resolution == "BUILD_NEW":
|
||||
obligations["verification"].append("positive gap evidence recorded in contract")
|
||||
for lo in trace["layout_obligations"]:
|
||||
obligations["verification"].append(lo["verification"])
|
||||
return obligations
|
||||
|
||||
|
||||
# ── contract generation (§15) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_contract(
|
||||
s, doc: dict, trace: dict, *, contract_id: str, scope: list[str] | None = None, proposer_note: str = ""
|
||||
) -> dict:
|
||||
"""Populate a Construction Contract from the decision trace (Tranche 5
|
||||
§13-17 IMPACT-AWARE generation). The Need's affected surface is never
|
||||
lost — it is classified and propagated into distinct surfaces:
|
||||
|
||||
affected_entities structural entity ids (existing)
|
||||
expected_new_entities planned new structural entities
|
||||
ext.affected_paths file-scope entries (mechanical scope check)
|
||||
ext.affected_ui_surfaces ui components (layout obligations follow)
|
||||
ext.unresolved_surfaces retained, never dropped
|
||||
ext.expected_runtime_effects operational effects (deployment:current
|
||||
advances etc.) — accounting metadata, NOT a
|
||||
workflow engine; closure requires independent
|
||||
observation before marking them observed.
|
||||
|
||||
File scope: generated from affected paths + selected capability
|
||||
implementation paths + explicit expected-new paths — NOT unrestricted
|
||||
repo scope. `scope` param may still extend it; generated paths are
|
||||
recorded as such (provenance: affected-surface).
|
||||
"""
|
||||
merged = trace["_merged"]
|
||||
req = trace["requirement"]
|
||||
surfaces = trace.get("affected_surfaces", {})
|
||||
selected = merged.get("selected") or []
|
||||
if isinstance(selected, str):
|
||||
selected = [selected]
|
||||
|
||||
# structural affected: selected candidates + requirement contracts +
|
||||
# resolved surface entities — the union the old generator partially lost
|
||||
affected = {c["id"] for c in trace["candidates"] if c["id"] in selected}
|
||||
affected |= set(trace["requirement"].get("contracts", []))
|
||||
affected |= set(surfaces.get("affected_existing_entities", []))
|
||||
|
||||
# file scope: generated (affected paths + selected impl paths + expected
|
||||
# new paths) UNION caller-supplied additions, provenance-tracked
|
||||
affected_paths = list(surfaces.get("affected_paths", []))
|
||||
impl_paths = []
|
||||
for c in trace["candidates"]:
|
||||
if c["id"] in selected:
|
||||
p = c.get("identities", {}).get("path", "")
|
||||
if p:
|
||||
impl_paths.append(p)
|
||||
expected_new_paths = trace["need"]["constraints"].get("expected_new_source_paths", [])
|
||||
generated_scope = sorted(
|
||||
set(affected_paths)
|
||||
| set(surfaces.get("workflow_config_surfaces", []))
|
||||
| set(impl_paths)
|
||||
| set(expected_new_paths)
|
||||
)
|
||||
caller_scope = sorted(set(scope or []) - set(generated_scope))
|
||||
final_scope = sorted(set(generated_scope) | set(caller_scope))
|
||||
|
||||
expected_runtime = sorted(trace["need"]["constraints"].get("expected_runtime_effects", []))
|
||||
|
||||
obl = trace["obligations"]
|
||||
s.new_contract(
|
||||
doc,
|
||||
contract_id=contract_id,
|
||||
need=trace["need"]["objective"],
|
||||
origin=trace["need"]["origin"],
|
||||
scope=final_scope,
|
||||
requirement=req.get("objective", ""),
|
||||
gap_evidence=merged.get("rationale", ""),
|
||||
research_refs=(
|
||||
trace["need"]["evidence_refs"]
|
||||
+ ([trace["research"]["route"]] if trace["research"]["route"] != "NONE" else [])
|
||||
),
|
||||
resolution=trace["resolution"],
|
||||
toolchain=trace["toolchain"],
|
||||
affected_entities=sorted(affected),
|
||||
expected_new_entities=sorted(
|
||||
set(trace["need"]["constraints"].get("expected_new_entities", []))
|
||||
| {d["entity"] for d in surfaces.get("derived_expected_new", [])}
|
||||
),
|
||||
expected_consumers=[],
|
||||
regression_obligations=obl["regression"],
|
||||
verification_obligations=sorted(set(obl["verification"])),
|
||||
rollback_ref=trace["need"]["constraints"].get("rollback_ref", "git revert"),
|
||||
acceptance=[f"resolution {trace['resolution']} verified end-to-end"],
|
||||
non_goals=trace["need"]["constraints"].get("non_goals", []),
|
||||
)
|
||||
e = s.contract(doc, contract_id)
|
||||
e.setdefault("ext", {}).update(
|
||||
{
|
||||
"decision_trace": {
|
||||
k: trace[k]
|
||||
for k in (
|
||||
"need",
|
||||
"requirement",
|
||||
"candidates",
|
||||
"fits",
|
||||
"resolution",
|
||||
"rationale",
|
||||
"research",
|
||||
"toolchain",
|
||||
"approval",
|
||||
"obligations",
|
||||
"layout_obligations",
|
||||
"normalization_missing",
|
||||
"completeness",
|
||||
"affected_surfaces",
|
||||
"iterations",
|
||||
"patterns_considered",
|
||||
"pattern_obligations",
|
||||
"construction_mode",
|
||||
)
|
||||
},
|
||||
# distinct impact surfaces (Tranche 5 §13) — file scope != structural
|
||||
# impact != expected new != runtime effect
|
||||
"affected_paths": affected_paths,
|
||||
"affected_ui_surfaces": surfaces.get("affected_ui_surfaces", []),
|
||||
"affected_regions": surfaces.get("affected_regions", []),
|
||||
"workflow_config_surfaces": surfaces.get("workflow_config_surfaces", []),
|
||||
"unresolved_surfaces": surfaces.get("unresolved_surfaces", []),
|
||||
"expected_runtime_effects": expected_runtime,
|
||||
"scope_provenance": {"generated_from_affected_surface": generated_scope, "caller_added": caller_scope},
|
||||
}
|
||||
)
|
||||
if proposer_note:
|
||||
e["ext"]["decision_trace"]["proposer_note"] = proposer_note
|
||||
return e
|
||||
@@ -0,0 +1,138 @@
|
||||
"""construction_depth.py — proportional Scaffolding depth selection.
|
||||
|
||||
SCAFFOLD-TRANCHE-0012 §12E. ONE construction discipline, variable depth.
|
||||
"Small" never means "bypass governance"; a one-line typo never creates
|
||||
six meaningless artefacts.
|
||||
|
||||
Depth is selected from EVIDENCE, not from keywords alone. The evidence
|
||||
inputs (§12E): construction mode, novelty, persistence, risk, affected
|
||||
surface, architecture impact, migration, deployment impact, user-locked
|
||||
structure, uncertainty.
|
||||
|
||||
Levels:
|
||||
LIGHT classification + authority/scope + minimal obligations +
|
||||
focused verification. For tiny bounded local fixes.
|
||||
STANDARD + requirements/completeness + capability/toolchain check
|
||||
+ broader verification. For features/repairs/migrations.
|
||||
FULL + project bootstrap + architecture/toolchain decisions +
|
||||
patterns + deployment/rollback. For PROJECT_BUILD.
|
||||
|
||||
Reuses existing semantics: construction mode (T10/T11) already encodes
|
||||
most of the signal — depth is DERIVED from mode + modifiers, not a
|
||||
parallel classification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["Depth", "select_depth", "LEVELS"]
|
||||
|
||||
LEVELS = ("NONE", "UNRESOLVED", "LIGHT", "STANDARD", "FULL")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Depth:
|
||||
level: str
|
||||
reason: str
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
ceremony: list[str] = field(default_factory=list) # what depth requires
|
||||
bypassed: list[str] = field(default_factory=list) # what is skipped and why
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"level": self.level, "reason": self.reason,
|
||||
"evidence": self.evidence, "ceremony": self.ceremony,
|
||||
"bypassed": self.bypassed}
|
||||
|
||||
|
||||
def select_depth(mode: str, *, objective: str = "",
|
||||
affected_surface: list[str] | None = None,
|
||||
has_migration: bool = False,
|
||||
breaking: bool = False,
|
||||
user_locked_layout: bool = False,
|
||||
external_service: bool = False,
|
||||
uncertainty: str = "", # "" | "partial" | "high"
|
||||
project_exists: bool | None = None,
|
||||
) -> Depth:
|
||||
"""Select proportional depth from construction mode + evidence."""
|
||||
ev: list[str] = [f"mode: {mode}"]
|
||||
ceremony: list[str] = []
|
||||
bypassed: list[str] = []
|
||||
|
||||
if mode == "NOT_CONSTRUCTION":
|
||||
# §13C: NOT_CONSTRUCTION has NO construction depth — not even LIGHT
|
||||
return Depth(level="NONE", reason="not construction — no construction "
|
||||
"depth applies", evidence=ev, ceremony=[],
|
||||
bypassed=["all"])
|
||||
|
||||
if mode == "UNKNOWN":
|
||||
# §13C: UNKNOWN means construction hasn't started — no lifecycle yet
|
||||
return Depth(level="UNRESOLVED",
|
||||
reason="UNKNOWN — probe/clarify before any construction "
|
||||
"lifecycle begins",
|
||||
evidence=ev, ceremony=["probe/clarify"],
|
||||
bypassed=["contract", "patterns", "bootstrap",
|
||||
"depth selection"])
|
||||
|
||||
if mode == "LOCAL_CHANGE":
|
||||
ev.append("LOCAL_CHANGE: bounded scope, minimal ceremony")
|
||||
ceremony.extend(["classification", "authority/scope",
|
||||
"focused verification", "durable result"])
|
||||
bypassed.extend(["requirements/completeness", "patterns",
|
||||
"capability/toolchain check", "project bootstrap",
|
||||
"deployment/rollback"])
|
||||
if user_locked_layout:
|
||||
ev.append("USER-LOCKED layout surface — preservation obligation "
|
||||
"retained even at LIGHT depth")
|
||||
ceremony.append("layout obligation check")
|
||||
return Depth(level="LIGHT",
|
||||
reason="bounded local fix — governance without ceremony",
|
||||
evidence=ev, ceremony=ceremony, bypassed=bypassed)
|
||||
|
||||
if mode == "PROJECT_BUILD":
|
||||
ev.append("PROJECT_BUILD: full lifecycle required")
|
||||
ceremony.extend(["project bootstrap", "requirements/completeness",
|
||||
"capability/toolchain decisions",
|
||||
"patterns/obligations", "Construction Contract",
|
||||
"implementation", "verification",
|
||||
"deployment/rollback strategy",
|
||||
"maintenance lifecycle"])
|
||||
return Depth(level="FULL",
|
||||
reason="genuinely new project — full Scaffolding "
|
||||
"lifecycle",
|
||||
evidence=ev, ceremony=ceremony)
|
||||
|
||||
# EXISTING_PROJECT_CHANGE: base STANDARD, escalate on modifiers
|
||||
ceremony.extend(["classification", "requirements/completeness",
|
||||
"capability fit", "Construction Contract", "scope",
|
||||
"obligations", "verification"])
|
||||
level = "STANDARD"
|
||||
reason = "existing-project change — proportional governance"
|
||||
|
||||
if has_migration:
|
||||
ev.append("migration: old/new compatibility + rollback obligations")
|
||||
ceremony.extend(["migration proof", "rollback preparation"])
|
||||
if breaking:
|
||||
ev.append("breaking change: stronger approval + regression "
|
||||
"obligations")
|
||||
ceremony.append("consumer migration completeness")
|
||||
if user_locked_layout:
|
||||
ev.append("USER-LOCKED layout surface affected")
|
||||
ceremony.append("layout obligation check")
|
||||
if external_service:
|
||||
ev.append("external service consequence")
|
||||
ceremony.append("deployment verification")
|
||||
|
||||
if uncertainty == "high":
|
||||
ev.append("high uncertainty: probe before deeper ceremony")
|
||||
ceremony.insert(0, "evidence probe")
|
||||
surface_list = affected_surface or []
|
||||
if len(surface_list) > 5:
|
||||
ev.append(f"wide affected surface: {len(surface_list)} entries")
|
||||
level = "FULL"
|
||||
reason = "wide surface — full depth warranted"
|
||||
if breaking and has_migration:
|
||||
level = "FULL"
|
||||
reason = "breaking + migration — full depth"
|
||||
|
||||
return Depth(level=level, reason=reason, evidence=ev, ceremony=ceremony,
|
||||
bypassed=bypassed)
|
||||
@@ -0,0 +1,974 @@
|
||||
"""construction_interface.py — the stable builder-facing Construction
|
||||
Interface (SCAFFOLD-TRANCHE-007).
|
||||
|
||||
ONE shared boundary for every builder — MiMo, Kimi, GLM, future models,
|
||||
deterministic scripts — and for both modes:
|
||||
|
||||
Rune-internal mode authority = Rune Spine + Rune's Project Scaffold
|
||||
external-project mode authority = that project's Project Scaffold
|
||||
|
||||
The builder must NOT need to know construction_decision.py, scaffold.py,
|
||||
structural_store.py, Layout Contract internals, or store paths. Those are
|
||||
implementation details behind this adapter.
|
||||
|
||||
Authority enforcement (§14) — the adapter is NOT a bypass:
|
||||
- builders may not rewrite Rune Spine or Project Scaffold truth
|
||||
- builders may not amend USER-LOCKED layout
|
||||
- builders may not silently broaden scope
|
||||
- builders may not self-certify completion ("done=true" is not a thing)
|
||||
- closing remains governed by Scaffolding (phase map + gates)
|
||||
- builder identity is RECORDED for audit but never changes semantics
|
||||
(§17): identical structured input → identical behavior, any builder.
|
||||
|
||||
Machine-readable output (§12): every operation returns a stable envelope —
|
||||
{schema, operation, success, ids, state, evidence, errors, warnings}
|
||||
Errors are explicit states (§13): NEED_INCOMPLETE, INSUFFICIENT_EVIDENCE,
|
||||
APPROVAL_REQUIRED, UNKNOWN_CONTRACT, SCOPE_VIOLATION, VERIFICATION_FAILED,
|
||||
STORE_WRITE_FAILED, STALE_STATE — never prose-only exit-1.
|
||||
|
||||
Transport (§3): typed Python API (this module) + JSON CLI (module main,
|
||||
same convention as rune-spine). No network service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rune import structural_store as ss
|
||||
from rune import scaffold as sc
|
||||
from rune import spine as spine_mod
|
||||
from rune import construction_decision as cd
|
||||
|
||||
__all__ = ["ConstructionInterface", "INTERFACE_SCHEMA_VERSION", "Errors"]
|
||||
|
||||
INTERFACE_SCHEMA_VERSION = 1
|
||||
|
||||
# ── explicit error states (§13) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class Errors:
|
||||
NEED_INCOMPLETE = "NEED_INCOMPLETE"
|
||||
INSUFFICIENT_EVIDENCE = "INSUFFICIENT_EVIDENCE"
|
||||
APPROVAL_REQUIRED = "APPROVAL_REQUIRED"
|
||||
UNKNOWN_CONTRACT = "UNKNOWN_CONTRACT"
|
||||
SCOPE_VIOLATION = "SCOPE_VIOLATION"
|
||||
VERIFICATION_FAILED = "VERIFICATION_FAILED"
|
||||
LAYOUT_VIOLATION = "LAYOUT_VIOLATION"
|
||||
PATTERN_CONFLICT = "PATTERN_CONFLICT"
|
||||
STORE_WRITE_FAILED = "STORE_WRITE_FAILED"
|
||||
STALE_STATE = "STALE_STATE"
|
||||
|
||||
|
||||
def _envelope(
|
||||
operation: str,
|
||||
*,
|
||||
success: bool,
|
||||
ids: dict | None = None,
|
||||
state: dict | None = None,
|
||||
evidence: list | None = None,
|
||||
errors: list | None = None,
|
||||
warnings: list | None = None,
|
||||
**extra,
|
||||
) -> dict:
|
||||
env = {
|
||||
"schema": INTERFACE_SCHEMA_VERSION,
|
||||
"operation": operation,
|
||||
"success": success,
|
||||
"ids": ids or {},
|
||||
"state": state or {},
|
||||
"evidence": evidence or [],
|
||||
"errors": errors or [],
|
||||
"warnings": warnings or [],
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
class ConstructionInterface:
|
||||
"""The builder-facing construction lifecycle for ONE project target.
|
||||
|
||||
Rune-internal mode (default):
|
||||
ConstructionInterface(builder="mimo")
|
||||
External-project mode:
|
||||
ConstructionInterface(builder="mimo",
|
||||
project_store="/path/to/.rune/scaffold",
|
||||
project_id="that-project")
|
||||
|
||||
`builder` is recorded for audit (session record, §20) and NEVER changes
|
||||
construction semantics (§17).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
builder: str = "unknown",
|
||||
*,
|
||||
project_store: str | None = None,
|
||||
project_id: str | None = None,
|
||||
spine_store: str = "store/spine/current.json",
|
||||
):
|
||||
if not builder:
|
||||
raise ValueError("builder identity required (recorded for audit)")
|
||||
self.builder = builder
|
||||
self.spine_store = spine_store
|
||||
if project_store:
|
||||
self.scaffold = sc.Scaffold(project_store, project_id or Path(project_store).name)
|
||||
self.mode = "external_project"
|
||||
else:
|
||||
self.scaffold = sc.Scaffold("store/scaffold", "issue161-harness")
|
||||
self.mode = "rune_internal"
|
||||
|
||||
# -- authority access (read-only projections for builders) ----------------
|
||||
|
||||
def _authorities(self) -> dict[str, dict]:
|
||||
"""The structural authorities for THIS mode. Rune-internal consults
|
||||
the real Spine store; external mode consults only the project
|
||||
scaffold (a Rune-independent project has no Rune Spine)."""
|
||||
auth: dict[str, dict] = {"project_scaffold": self.scaffold.load()}
|
||||
if self.mode == "rune_internal":
|
||||
sd = spine_mod.load(Path(self.spine_store))
|
||||
if sd is not None:
|
||||
auth["rune_spine"] = sd
|
||||
return auth
|
||||
|
||||
def _record_session(self, doc: dict, operation: str, detail: str) -> None:
|
||||
"""Builder session record (§20): compact, no transcripts."""
|
||||
doc.setdefault("observations", []).append(
|
||||
{
|
||||
"kind": "builder_session",
|
||||
"builder": self.builder,
|
||||
"mode": self.mode,
|
||||
"operation": operation,
|
||||
"detail": detail[:200],
|
||||
"observed_at": ss.now(),
|
||||
}
|
||||
)
|
||||
|
||||
# ── §5 submit_need ────────────────────────────────────────────────────────
|
||||
|
||||
def classify(self, objective: str, *, target_project: str | None = None,
|
||||
project_exists: bool | None = None,
|
||||
explicit_override: str | None = None,
|
||||
override_authority: str | None = None) -> dict:
|
||||
"""§11/§15: contextual construction-mode classification through the
|
||||
ONE canonical classifier. Builders (MiMo etc.) and Rune's native
|
||||
backend get identical semantics. UNKNOWN comes back with probe
|
||||
questions, never a silent choice."""
|
||||
from rune.construction_mode import classify_mode
|
||||
d = classify_mode(
|
||||
objective, target_project=target_project,
|
||||
project_exists=project_exists,
|
||||
explicit_override=explicit_override,
|
||||
override_authority=override_authority or self.builder)
|
||||
env = _envelope("classify", success=d.mode != "UNKNOWN",
|
||||
state={**d.to_dict(), "builder": self.builder})
|
||||
return env
|
||||
|
||||
def construction_context(self, contract_id: str | None = None) -> dict:
|
||||
"""§15 SHARED CONSTRUCTION CONTEXT: typed projection derived from
|
||||
canonical authorities (contract store, decision trace, verification
|
||||
state). Serves Rune TUI + Construction Interface without duplicating
|
||||
state machines. NOT a second authority."""
|
||||
doc = self.scaffold.load()
|
||||
ctx: dict = {"mode": None, "project": self.scaffold.project_id,
|
||||
"builder": self.builder}
|
||||
contracts = [e for e in doc["entities"]
|
||||
if e["kind"] == "construction_contract"]
|
||||
target = None
|
||||
if contract_id:
|
||||
target = self.scaffold.contract(doc, contract_id)
|
||||
else:
|
||||
# most recent non-closed contract = the active construction
|
||||
open_contracts = [e for e in contracts
|
||||
if e.get("status") == "open"]
|
||||
target = (sorted(open_contracts,
|
||||
key=lambda e: e.get("observed_at", ""))[-1]
|
||||
if open_contracts else None)
|
||||
if target is None:
|
||||
ctx.update({"active": False,
|
||||
"note": "no active construction"})
|
||||
return _envelope("construction_context", success=True,
|
||||
state=ctx)
|
||||
t = target.get("ext", {}).get("decision_trace", {})
|
||||
ctx.update({
|
||||
"active": True,
|
||||
"contract_id": target["id"].split(":", 1)[1],
|
||||
"need": t.get("need", {}).get("objective", "")[:200],
|
||||
"resolution": t.get("resolution", ""),
|
||||
"construction_mode": t.get("construction_mode", {}).get("mode"),
|
||||
"phase": target.get("phase"), "status": target.get("status"),
|
||||
"scope": target["identities"].get("scope", []),
|
||||
"obligations": _obligations_projection(target),
|
||||
"patterns": target.get("ext", {}).get("pattern_refs", []),
|
||||
"approval": t.get("approval", {}),
|
||||
"completeness": t.get("completeness", {}).get("verdict"),
|
||||
"unresolved": target.get("ext", {}).get("unresolved_surfaces", []),
|
||||
"next_action": target["identities"].get("next_action", ""),
|
||||
})
|
||||
return _envelope("construction_context", success=True, state=ctx)
|
||||
|
||||
def capabilities(self) -> dict:
|
||||
"""§28 schema negotiation: builders detect the supported interface
|
||||
schema and operation set BEFORE calling operations. Also exposes the
|
||||
error-state vocabulary so builders can switch on states without
|
||||
parsing prose. Read-only; no contract required.
|
||||
§15N: full argument schemas included — a fresh builder discovers
|
||||
required types/fields deterministically instead of trial-and-error
|
||||
(the T14 report_observation dict-vs-list + missing-entity mistakes
|
||||
are exactly what this prevents)."""
|
||||
return _envelope(
|
||||
"capabilities",
|
||||
success=True,
|
||||
state={
|
||||
"schema_version": INTERFACE_SCHEMA_VERSION,
|
||||
"mode": self.mode,
|
||||
"project": self.scaffold.project_id,
|
||||
"operations": [
|
||||
"discover_active_contract",
|
||||
"submit_need",
|
||||
"inspect_decision",
|
||||
"get_scope",
|
||||
"get_obligations",
|
||||
"report_observation",
|
||||
"verify",
|
||||
"transition",
|
||||
"close",
|
||||
"resume",
|
||||
"amend_scope",
|
||||
"capabilities",
|
||||
],
|
||||
"error_states": sorted(v for k, v in vars(Errors).items() if not k.startswith("_")),
|
||||
# ── §15N argument schemas (discovery over guessing) ──
|
||||
"argument_schemas": {
|
||||
"submit_need": {
|
||||
"objective": {"type": "str", "required": True},
|
||||
"origin": {"type": "str", "default": "builder"},
|
||||
"constraints": {"type": "dict", "required": False,
|
||||
"notes": ("{'environment': {...}, "
|
||||
"'contracts': [...]} — "
|
||||
"environment missing "
|
||||
"degrades to "
|
||||
"INSUFFICIENT_EVIDENCE")},
|
||||
"affected_surface": {"type": "list[str]",
|
||||
"required": False},
|
||||
"evidence_refs": {"type": "list[str]",
|
||||
"required": True,
|
||||
"notes": ("empty list degrades "
|
||||
"fit resolution")},
|
||||
"approval_context": {"type": "str",
|
||||
"required": False},
|
||||
},
|
||||
"report_observation": {
|
||||
"observations": {
|
||||
"type": "list[dict]",
|
||||
"required": True,
|
||||
"item_required_fields": ["entity"],
|
||||
"item_optional_fields": ["source", "detail"],
|
||||
"notes": ("a LIST of dicts, each with an "
|
||||
"'entity' key — a bare dict or a "
|
||||
"dict without 'entity' is rejected "
|
||||
"(NEED_INCOMPLETE)"),
|
||||
},
|
||||
},
|
||||
"verify": {
|
||||
"changed_files": {"type": "list[str] | None",
|
||||
"required": False,
|
||||
"notes": ("scope-checked against "
|
||||
"the contract scope; "
|
||||
"out-of-scope files → "
|
||||
"SCOPE_VIOLATION")},
|
||||
},
|
||||
"transition": {
|
||||
"phase": {"type": "str", "required": True,
|
||||
"allowed": list(sc.PHASES)},
|
||||
},
|
||||
"close": {
|
||||
"observed_evidence": {
|
||||
"type": "list[dict]", "required": True,
|
||||
"notes": ("evidence INPUT only — closure is "
|
||||
"decided by verification truth, "
|
||||
"never by builder claim")},
|
||||
"scope_files": {"type": "list[str] | None",
|
||||
"required": False},
|
||||
},
|
||||
},
|
||||
"lifecycle": {
|
||||
"phases": list(sc.PHASES),
|
||||
"legal_transitions": {k: sorted(v) for k, v in
|
||||
sc.LEGAL_TRANSITIONS.items()},
|
||||
"scope_amendment": ("user-gated via amend_scope — "
|
||||
"builders cannot broaden scope"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def submit_need(
|
||||
self,
|
||||
objective: str,
|
||||
*,
|
||||
origin: str = "builder",
|
||||
constraints: dict | None = None,
|
||||
affected_surface: list[str] | None = None,
|
||||
evidence_refs: list[str] | None = None,
|
||||
approval_context: str = "",
|
||||
task_ref: str = "",
|
||||
generate: bool = True,
|
||||
contract_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Submit a structured Need; run the full live decision path;
|
||||
optionally generate the Construction Contract. Returns decision
|
||||
summary + contract ref + outstanding questions. Does NOT decide
|
||||
approval — that surfaces via state.approval."""
|
||||
if not objective or not objective.strip():
|
||||
return _envelope(
|
||||
"submit_need",
|
||||
success=False,
|
||||
errors=[{"error": Errors.NEED_INCOMPLETE, "detail": "objective is required"}],
|
||||
)
|
||||
need = cd.Need(
|
||||
need_id=f"need-{ss.now()}-{self.builder}",
|
||||
objective=objective,
|
||||
origin=origin,
|
||||
project=self.scaffold.project_id,
|
||||
task_ref=task_ref,
|
||||
constraints=dict(constraints or {}),
|
||||
affected_surface=list(affected_surface or []),
|
||||
evidence_refs=list(evidence_refs or []),
|
||||
approval_context=approval_context,
|
||||
)
|
||||
doc = self.scaffold.load()
|
||||
authorities = self._authorities()
|
||||
trace = cd.make_decision(need, authorities=authorities, project_toolchain={"existing_language": "python"})
|
||||
ids: dict = {"need_id": need.need_id}
|
||||
state: dict = {
|
||||
"resolution": trace["resolution"],
|
||||
"completeness": trace["completeness"]["verdict"],
|
||||
"research": trace["research"],
|
||||
"approval": trace["approval"],
|
||||
"builder": self.builder,
|
||||
}
|
||||
errors, warnings = [], []
|
||||
if trace["completeness"]["verdict"] == cd.MATERIAL_GAP:
|
||||
errors.append(
|
||||
{
|
||||
"error": Errors.INSUFFICIENT_EVIDENCE,
|
||||
"detail": "material requirement gap",
|
||||
"gaps": trace["completeness"]["gaps"],
|
||||
"probe_questions": trace["completeness"]["probe_questions"],
|
||||
}
|
||||
)
|
||||
if trace["obligations"].get("pattern_conflicts"):
|
||||
errors.append(
|
||||
{
|
||||
"error": Errors.PATTERN_CONFLICT,
|
||||
"detail": "pattern obligations conflict",
|
||||
"conflicts": trace["obligations"]["pattern_conflicts"],
|
||||
}
|
||||
)
|
||||
if trace["approval"]["required"]:
|
||||
errors.append({"error": Errors.APPROVAL_REQUIRED, "reasons": trace["approval"]["reasons"]})
|
||||
if trace["resolution"] == "INSUFFICIENT_EVIDENCE" and not errors:
|
||||
errors.append({"error": Errors.INSUFFICIENT_EVIDENCE, "detail": trace["rationale"][:200]})
|
||||
contract = None
|
||||
if generate and trace["resolution"] not in ("INSUFFICIENT_EVIDENCE",):
|
||||
cid = contract_id or f"BUILDER-{ss.now()}-{self.builder}"
|
||||
cd.generate_contract(
|
||||
self.scaffold,
|
||||
doc,
|
||||
trace,
|
||||
contract_id=cid,
|
||||
scope=None,
|
||||
proposer_note=f"via Construction Interface by {self.builder}",
|
||||
)
|
||||
ids["contract_id"] = cid
|
||||
state["contract_phase"] = "draft"
|
||||
state["next_action"] = "review for approval"
|
||||
warnings.append({"warning": "contract generated in draft; requires approval transition before building"})
|
||||
self._record_session(doc, "submit_need", f"{need.need_id} → {trace['resolution']}")
|
||||
try:
|
||||
self.scaffold.commit(doc, what="submit_need")
|
||||
except (OSError, ValueError) as e:
|
||||
return _envelope(
|
||||
"submit_need",
|
||||
success=False,
|
||||
ids=ids,
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": str(e)[:200]}],
|
||||
)
|
||||
return _envelope("submit_need", success=not errors, ids=ids, state=state, errors=errors, warnings=warnings)
|
||||
|
||||
# ── §6 inspect_decision ───────────────────────────────────────────────────
|
||||
|
||||
def inspect_decision(self, contract_id: str) -> dict:
|
||||
"""Read-only projection of the durable decision trace (§6)."""
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"inspect_decision",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
t = c.get("ext", {}).get("decision_trace", {})
|
||||
return _envelope(
|
||||
"inspect_decision",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id, "need_id": t.get("need", {}).get("need_id", "")},
|
||||
state={
|
||||
"requirement": t.get("requirement", {}),
|
||||
"completeness": t.get("completeness", {}),
|
||||
"candidates": t.get("candidates", []),
|
||||
"fits": [f for f in t.get("fits", []) if f["fit"] != "UNKNOWN"],
|
||||
"resolution": t.get("resolution", ""),
|
||||
"rationale": t.get("rationale", ""),
|
||||
"toolchain": t.get("toolchain", {}),
|
||||
"patterns_considered": t.get("patterns_considered", []),
|
||||
"structural_obligations": {
|
||||
k: t.get("obligations", {}).get(k, [])
|
||||
for k in ("regression", "verification", "composition", "acquisition")
|
||||
},
|
||||
"pattern_obligations": t.get("obligations", {}).get("pattern", []),
|
||||
"approval": t.get("approval", {}),
|
||||
"iterations": t.get("iterations", []),
|
||||
"builder": self.builder,
|
||||
},
|
||||
)
|
||||
|
||||
# ── §7 get_scope ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_scope(self, contract_id: str) -> dict:
|
||||
"""Mechanical builder-facing scope (§7): what may I touch?"""
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"get_scope",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
ext = c.get("ext", {})
|
||||
return _envelope(
|
||||
"get_scope",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id},
|
||||
state={
|
||||
"allowed_paths": c["identities"]["scope"],
|
||||
"affected_structural_entities": c["identities"]["affected_entities"],
|
||||
"expected_new_entities": c["identities"].get("expected_new_entities", []),
|
||||
"affected_paths": ext.get("affected_paths", []),
|
||||
"affected_ui_surfaces": ext.get("affected_ui_surfaces", []),
|
||||
"workflow_config_surfaces": ext.get("workflow_config_surfaces", []),
|
||||
"unresolved_surfaces": ext.get("unresolved_surfaces", []),
|
||||
"expected_runtime_effects": ext.get("expected_runtime_effects", []),
|
||||
"layout_obligations": t_layout(ext),
|
||||
"scope_amendments": ext.get("scope_amendments", []),
|
||||
"builder": self.builder,
|
||||
},
|
||||
)
|
||||
|
||||
def get_obligations(self, contract_id: str) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"get_obligations",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
return _envelope(
|
||||
"get_obligations",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id},
|
||||
state={"obligations": _obligations_projection(c), "builder": self.builder},
|
||||
)
|
||||
|
||||
def report_observation(self, contract_id: str, observations: Any) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
if not isinstance(observations, list) or not observations:
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.NEED_INCOMPLETE, "detail": "observations list required"}],
|
||||
)
|
||||
items = []
|
||||
for o in observations:
|
||||
if not isinstance(o, dict) or not o.get("entity"):
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.NEED_INCOMPLETE, "detail": "each observation needs an entity"}],
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"entity": o["entity"],
|
||||
"source": o.get("source", f"builder:{self.builder}"),
|
||||
"detail": str(o.get("detail", ""))[:300],
|
||||
}
|
||||
)
|
||||
ss.record_observed_impact(doc, contract_id, items)
|
||||
self._record_session(doc, "report_observation", f"{len(items)} observation(s) on {contract_id}")
|
||||
try:
|
||||
self.scaffold.commit(doc, what="observations")
|
||||
except (OSError, ValueError) as e:
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": str(e)[:200]}],
|
||||
)
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id},
|
||||
state={
|
||||
"recorded": len(items),
|
||||
"note": "evidence recorded; satisfaction is determined by Scaffolding gates, not builder claims",
|
||||
},
|
||||
)
|
||||
|
||||
def verify(
|
||||
self, contract_id: str, *, changed_files: list[str] | None = None, layout_observations: dict | None = None
|
||||
) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"verify",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
state: dict = {"phase": c.get("phase"), "status": c.get("status")}
|
||||
errors, warnings = [], []
|
||||
if changed_files is not None:
|
||||
scope = self.scaffold.check_scope(doc, contract_id, changed_files)
|
||||
state["scope_check"] = scope
|
||||
if scope["verdict"] == "red":
|
||||
errors.append({"error": Errors.SCOPE_VIOLATION, "out_of_scope": scope["out_of_scope"]})
|
||||
elif scope["verdict"] == "unknown":
|
||||
warnings.append({"warning": "scope UNKNOWN — contract has no approved scope paths"})
|
||||
if layout_observations:
|
||||
from rune import layout_contract as lc
|
||||
|
||||
gate = lc.check_layout(doc, layout_observations)
|
||||
state["layout_gate"] = gate["verdict"]
|
||||
if gate["verdict"] == "FAIL":
|
||||
errors.append(
|
||||
{"error": Errors.LAYOUT_VIOLATION, "checks": [x for x in gate["checks"] if x["verdict"] == "FAIL"]}
|
||||
)
|
||||
elif gate["verdict"] == "UNKNOWN":
|
||||
warnings.append({"warning": "layout UNKNOWN — never PASS"})
|
||||
obligations = _obligations_projection(c)
|
||||
q = c.get("ext", {}).get("contract_quality")
|
||||
if q:
|
||||
state["contract_quality"] = {
|
||||
k: q[k]
|
||||
for k in (
|
||||
"correctly_predicted",
|
||||
"expected_new_observed",
|
||||
"expected_runtime_observed",
|
||||
"genuinely_unexpected_new",
|
||||
"scope_check",
|
||||
)
|
||||
if k in q
|
||||
}
|
||||
state["obligations_total"] = len(obligations)
|
||||
state["remaining"] = (
|
||||
"closure requires phase transitions through verifying with evidence; builder cannot self-certify"
|
||||
)
|
||||
return _envelope(
|
||||
"verify",
|
||||
success=not errors,
|
||||
ids={"contract_id": contract_id},
|
||||
state=state,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def transition(self, contract_id: str, phase: str, *, evidence_ref: str = "") -> dict:
|
||||
doc = self.scaffold.load()
|
||||
try:
|
||||
self.scaffold.transition_contract(
|
||||
doc, contract_id, phase, evidence_ref=evidence_ref or f"builder:{self.builder}"
|
||||
)
|
||||
except sc.IllegalPhaseTransition as e:
|
||||
return _envelope(
|
||||
"transition",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.VERIFICATION_FAILED, "detail": str(e)}],
|
||||
)
|
||||
except ValueError as e:
|
||||
return _envelope(
|
||||
"transition",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": str(e)}],
|
||||
)
|
||||
self._record_session(doc, "transition", f"{contract_id} → {phase}")
|
||||
try:
|
||||
self.scaffold.commit(doc, what="transition")
|
||||
except (OSError, ValueError) as e:
|
||||
return _envelope(
|
||||
"transition",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": str(e)[:200]}],
|
||||
)
|
||||
return _envelope("transition", success=True, ids={"contract_id": contract_id}, state={"phase": phase})
|
||||
|
||||
def close(self, contract_id: str, *, observed_evidence: list[dict], scope_files: list[str] | None = None) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
try:
|
||||
self.scaffold.close_contract(doc, contract_id, observed_evidence=observed_evidence, scope_files=scope_files)
|
||||
except sc.IllegalPhaseTransition as e:
|
||||
return _envelope(
|
||||
"close",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.VERIFICATION_FAILED, "detail": str(e)}],
|
||||
)
|
||||
except ValueError as e:
|
||||
return _envelope(
|
||||
"close",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": str(e)}],
|
||||
)
|
||||
self._record_session(doc, "close", f"{contract_id} closed")
|
||||
try:
|
||||
self.scaffold.commit(doc, what="close")
|
||||
except (OSError, ValueError) as e:
|
||||
return _envelope(
|
||||
"close",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": str(e)[:200]}],
|
||||
)
|
||||
closed = self.scaffold.contract(doc, contract_id)
|
||||
if closed is None:
|
||||
return _envelope(
|
||||
"close",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": "contract vanished after close"}],
|
||||
)
|
||||
q = closed["ext"]["contract_quality"]
|
||||
return _envelope(
|
||||
"close",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id},
|
||||
state={
|
||||
"phase": "closed",
|
||||
"contract_quality": {
|
||||
k: q[k]
|
||||
for k in ("correctly_predicted", "expected_new_observed", "genuinely_unexpected_new", "scope_check")
|
||||
if k in q
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def discover_active_contract(store_path: str) -> dict:
|
||||
"""§15U canonical resume discovery: walk the store's project
|
||||
scaffolds AND the main store's own current.json, returning the
|
||||
most recent contract's (project, contract_id). Migrated here from
|
||||
the T14/T15 session drivers — a fresh builder with NO transcript
|
||||
discovers what to resume through the INTERFACE, not a script.
|
||||
Pure readback: no session file, no transcript, no mutation."""
|
||||
from pathlib import Path as _P
|
||||
import json as _json
|
||||
root = _P(store_path)
|
||||
candidates: list[tuple] = []
|
||||
for proj_current in sorted(root.glob("projects/*/current.json")):
|
||||
# per-project scaffold: (file, project_id)
|
||||
candidates.append((proj_current, proj_current.parent.name))
|
||||
main = root / "current.json"
|
||||
if main.exists():
|
||||
# main store: contracts carry their project in identities
|
||||
candidates.append((main, None))
|
||||
if not candidates:
|
||||
return _envelope("discover_active_contract", success=False,
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT,
|
||||
"detail": "no projects in store"}])
|
||||
best = None
|
||||
best_project = ""
|
||||
for proj_current, project in candidates:
|
||||
try:
|
||||
doc = _json.loads(proj_current.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] == "construction_contract":
|
||||
proj = project or e.get("identities", {}).get(
|
||||
"project", root.name)
|
||||
key = (e.get("observed_at") or "", proj,
|
||||
e["id"].split(":", 1)[1])
|
||||
if best is None or key > (best[0], best[1], best[2]):
|
||||
best = key
|
||||
best_project = proj
|
||||
if best is None:
|
||||
return _envelope("discover_active_contract", success=False,
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT,
|
||||
"detail": "no contracts in store"}])
|
||||
return _envelope("discover_active_contract", success=True,
|
||||
ids={"project": best_project,
|
||||
"contract_id": best[2]})
|
||||
|
||||
def resume(self, contract_id: str) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"resume",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
ext = c.get("ext", {})
|
||||
t = ext.get("decision_trace", {})
|
||||
need_fallback = {
|
||||
"objective": c["identities"].get("need", ""),
|
||||
"origin": c["identities"].get("origin", ""),
|
||||
"need_id": contract_id,
|
||||
}
|
||||
resolution_fallback = c["identities"].get("resolution", "")
|
||||
toolchain_fallback = c["identities"].get("toolchain", {}).get("selected_language", "")
|
||||
return _envelope(
|
||||
"resume",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id, "need_id": t.get("need", {}).get("need_id", contract_id)},
|
||||
state={
|
||||
"mode": self.mode,
|
||||
"need": t.get("need", need_fallback),
|
||||
"decision": {
|
||||
"resolution": t.get("resolution") or resolution_fallback,
|
||||
"completeness": t.get("completeness", {}).get("verdict", ""),
|
||||
"toolchain": t.get("toolchain", {}).get("selected_language", "") or toolchain_fallback,
|
||||
},
|
||||
"phase": c.get("phase"),
|
||||
"status": c.get("status"),
|
||||
"scope": c["identities"]["scope"],
|
||||
"obligations": _obligations_projection(c),
|
||||
"patterns": c.get("ext", {}).get("pattern_refs", []),
|
||||
"evidence": [cp.get("evidence", "")[:80] for cp in c.get("checkpoints", [])],
|
||||
"unresolved": ext.get("unresolved_surfaces", []),
|
||||
"approval": t.get("approval", {}),
|
||||
"next_action": c["identities"].get("next_action", ""),
|
||||
"last_builder_observations": [
|
||||
o
|
||||
for o in doc.get("observations", [])
|
||||
if o.get("kind") == "observed_consequence" and o.get("task") == contract_id
|
||||
][-5:],
|
||||
"builder": self.builder,
|
||||
},
|
||||
)
|
||||
|
||||
def amend_scope(self, contract_id: str, add_paths: list[str], *, reason: str, authority: str = "user") -> dict:
|
||||
if authority != "user":
|
||||
return _envelope(
|
||||
"amend_scope",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[
|
||||
{
|
||||
"error": Errors.APPROVAL_REQUIRED,
|
||||
"detail": "scope expansion requires user authority — builders cannot broaden their own scope",
|
||||
}
|
||||
],
|
||||
)
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"amend_scope",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
c["identities"]["scope"] = sorted(set(c["identities"]["scope"]) | set(add_paths))
|
||||
c.setdefault("ext", {}).setdefault("scope_amendments", []).append(
|
||||
{"added": sorted(add_paths), "reason": reason[:200], "authorized_by": "user"}
|
||||
)
|
||||
self._record_session(doc, "amend_scope", f"{contract_id} +{len(add_paths)} paths (user)")
|
||||
self.scaffold.commit(doc, what=f"amend_scope {contract_id}")
|
||||
return _envelope(
|
||||
"amend_scope", success=True, ids={"contract_id": contract_id}, state={"scope": c["identities"]["scope"]}
|
||||
)
|
||||
|
||||
# explicit method binding (readable, debuggable — no locals() tricks)
|
||||
|
||||
|
||||
def t_layout(ext: dict) -> list[dict]:
|
||||
"""Layout obligations from a contract's decision trace (helper)."""
|
||||
return ext.get("decision_trace", {}).get("layout_obligations", [])
|
||||
|
||||
|
||||
# ── §8 get_obligations / §9 report_observation / §10 verification ───────────
|
||||
|
||||
|
||||
def _obligations_projection(c: dict) -> list[dict]:
|
||||
t = c.get("ext", {}).get("decision_trace", {})
|
||||
obl = t.get("obligations", {})
|
||||
out = []
|
||||
# contract-level obligations (from identities, not just trace)
|
||||
ident = c.get("identities", {})
|
||||
for field_name, prov in (
|
||||
("verification_obligations", "CONTRACT"),
|
||||
("regression_obligations", "CONTRACT"),
|
||||
):
|
||||
for item in ident.get(field_name, []):
|
||||
out.append(
|
||||
{
|
||||
"id": f"{prov.lower()}:{abs(hash(str(item))) % 10**8}",
|
||||
"description": str(item)[:200],
|
||||
"provenance": prov,
|
||||
"kind": field_name,
|
||||
"status": "REQUIRED",
|
||||
}
|
||||
)
|
||||
for kind in ("regression", "verification", "composition", "acquisition"):
|
||||
for item in obl.get(kind, []):
|
||||
out.append(
|
||||
{
|
||||
"id": f"{kind}:{abs(hash(str(item))) % 10**8}",
|
||||
"description": str(item)[:200],
|
||||
"provenance": "STRUCTURAL",
|
||||
"kind": kind,
|
||||
"status": "REQUIRED",
|
||||
}
|
||||
)
|
||||
for p in obl.get("pattern", []):
|
||||
out.append(
|
||||
{
|
||||
"id": p["id"],
|
||||
"description": p["obligation"],
|
||||
"provenance": "PATTERN",
|
||||
"pattern": f"{p['pattern']}@r{p['revision']}",
|
||||
"pattern_applicability": p.get("pattern_applicability", ""),
|
||||
"evidence": p.get("reason", ""),
|
||||
"status": p.get("verdict", "REQUIRED"),
|
||||
}
|
||||
)
|
||||
for lo in t.get("layout_obligations", []):
|
||||
out.append(
|
||||
{
|
||||
"id": f"layout:{lo['component']}",
|
||||
"description": lo["obligation"],
|
||||
"provenance": "LAYOUT_CONTRACT",
|
||||
"status": "REQUIRED",
|
||||
"verification": lo["verification"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _obligation_scope_files(obligations: list[dict]) -> list[str]:
|
||||
"""Obligation ids referenced by closure scope checks."""
|
||||
return []
|
||||
|
||||
|
||||
# ── CLI transport (§3/§12/§27) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
"""JSON CLI — same convention as rune-spine/rune-scaffold mains. Every
|
||||
operation prints one JSON envelope; exit 0 = success, 1 = operation
|
||||
failure (the envelope carries the explicit error state)."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
cmd = argv[0]
|
||||
args = argv[1:]
|
||||
builder = "cli"
|
||||
project_store = None
|
||||
project_id = None
|
||||
# --builder / --project-store / --project-id flags
|
||||
for flag, key in (("--builder", "b"), ("--project-store", "s"), ("--project-id", "p")):
|
||||
while flag in args:
|
||||
i = args.index(flag)
|
||||
val = args[i + 1]
|
||||
del args[i : i + 2]
|
||||
if flag == "--builder":
|
||||
builder = val
|
||||
elif flag == "--project-store":
|
||||
project_store = val
|
||||
else:
|
||||
project_id = val
|
||||
|
||||
iface = ConstructionInterface(builder=builder, project_store=project_store, project_id=project_id)
|
||||
|
||||
def _out(env: dict) -> int:
|
||||
print(json.dumps(env, indent=1, default=str))
|
||||
return 0 if env["success"] else 1
|
||||
|
||||
if cmd == "capabilities":
|
||||
return _out(iface.capabilities())
|
||||
if cmd == "classify":
|
||||
payload = json.loads(args[0]) if args else {}
|
||||
return _out(iface.classify(
|
||||
payload.get("objective", ""),
|
||||
target_project=payload.get("target_project"),
|
||||
project_exists=payload.get("project_exists"),
|
||||
explicit_override=payload.get("explicit_override"),
|
||||
override_authority=payload.get("override_authority")))
|
||||
if cmd == "context":
|
||||
return _out(iface.construction_context(args[0] if args else None))
|
||||
if cmd == "submit-need":
|
||||
payload = json.loads(args[0]) if args else {}
|
||||
return _out(
|
||||
iface.submit_need(
|
||||
payload.get("objective", ""),
|
||||
origin=payload.get("origin", "builder"),
|
||||
constraints=payload.get("constraints"),
|
||||
affected_surface=payload.get("affected_surface"),
|
||||
evidence_refs=payload.get("evidence_refs"),
|
||||
approval_context=payload.get("approval_context", ""),
|
||||
generate=payload.get("generate", True),
|
||||
)
|
||||
)
|
||||
if cmd == "decision":
|
||||
return _out(iface.inspect_decision(args[0]))
|
||||
if cmd == "scope":
|
||||
return _out(iface.get_scope(args[0]))
|
||||
if cmd == "obligations":
|
||||
return _out(iface.get_obligations(args[0]))
|
||||
if cmd == "observe":
|
||||
return _out(iface.report_observation(args[0], json.loads(args[1])))
|
||||
if cmd == "verify":
|
||||
return _out(iface.verify(args[0], changed_files=(json.loads(args[1]) if len(args) > 1 else None)))
|
||||
if cmd == "transition":
|
||||
return _out(iface.transition(args[0], args[1], evidence_ref=args[2] if len(args) > 2 else ""))
|
||||
if cmd == "close":
|
||||
return _out(
|
||||
iface.close(
|
||||
args[0],
|
||||
observed_evidence=json.loads(args[1]),
|
||||
scope_files=(json.loads(args[2]) if len(args) > 2 else None),
|
||||
)
|
||||
)
|
||||
if cmd == "resume":
|
||||
return _out(iface.resume(args[0]))
|
||||
return _out(
|
||||
_envelope(cmd, success=False, errors=[{"error": "UNKNOWN_OPERATION", "detail": f"unknown command: {cmd}"}])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,372 @@
|
||||
"""construction_mode.py — contextual construction classification.
|
||||
|
||||
SCAFFOLD-TRANCHE-0010. ONE canonical home for the construction-mode
|
||||
decision (§2/§24: no competing router). Consumers — Rune's native backend
|
||||
path, the Construction Interface (MiMo/other builders), manual operator
|
||||
override — all call THIS.
|
||||
|
||||
The distinction (§3-4):
|
||||
|
||||
NOT_CONSTRUCTION not a build request at all
|
||||
LOCAL_CHANGE tiny fix / bounded patch — governance without
|
||||
ceremony (lightest path)
|
||||
EXISTING_PROJECT_CHANGE feature/repair/refactor/migration INSIDE an
|
||||
existing project — attaches to the existing
|
||||
Project Scaffold / Rune Spine
|
||||
PROJECT_BUILD genuinely NEW app/tool/service/project —
|
||||
fuller lifecycle (identity, repo decision,
|
||||
PRD state, bootstrap)
|
||||
UNKNOWN insufficient evidence — ask/probe, NEVER
|
||||
silently choose
|
||||
|
||||
KEY SEMANTIC DISTINCTION (§3): capability resolution BUILD_NEW does NOT
|
||||
imply PROJECT_BUILD. Building a new parser module inside Rune is
|
||||
EXISTING_PROJECT_CHANGE. Conversely "build me a standalone scheduler app"
|
||||
is PROJECT_BUILD before any capability decision exists. Tested explicitly.
|
||||
|
||||
NOT keyword-authoritative (§4): "build confidence" / "make a list" do not
|
||||
become PROJECT_BUILD. Deterministic evidence comes from:
|
||||
- explicit target project identity (constraint or recorded binding)
|
||||
- whether a Project Scaffold exists for the target
|
||||
- standalone/own-lifecycle language ("standalone", "new app",
|
||||
"new service", "own repository")
|
||||
- scoping words ("this", "existing", "the module") pointing at a
|
||||
known target
|
||||
Deterministic evidence is insufficient → UNKNOWN with probe questions.
|
||||
Model interpretation may be PROPOSED (recorded, PENDING — never authority,
|
||||
§5/§12 of the broader architecture).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["ConstructionMode", "classify_mode", "MODES"]
|
||||
|
||||
MODES = ("NOT_CONSTRUCTION", "LOCAL_CHANGE", "EXISTING_PROJECT_CHANGE", "PROJECT_BUILD", "UNKNOWN")
|
||||
|
||||
# deterministic lexical evidence — presence contributes, absence never
|
||||
# decides alone; every rule requires corroboration (§4)
|
||||
_STANDALONE_WORDS = {
|
||||
"standalone",
|
||||
"new app",
|
||||
"new application",
|
||||
"new service",
|
||||
"new tool",
|
||||
"new project",
|
||||
"new repository",
|
||||
"own repo",
|
||||
"from scratch",
|
||||
"separate app",
|
||||
"separate service",
|
||||
"greenfield",
|
||||
"scaffold a new",
|
||||
"new web",
|
||||
}
|
||||
# T15 §15J: extensionless filenames that bind a scoped target when
|
||||
# mentioned explicitly (dotted/path tokens go through classify_surface)
|
||||
_KNOWN_FILES = {"readme", "makefile", "dockerfile", "changelog", "license",
|
||||
"gemfile", "procfile", "jenkinsfile", "justfile"}
|
||||
_LOCAL_WORDS = {
|
||||
"typo",
|
||||
"small fix",
|
||||
"tiny fix",
|
||||
"quick fix",
|
||||
"patch",
|
||||
"minor",
|
||||
"one-liner",
|
||||
"cosmetic",
|
||||
"comment fix",
|
||||
"small correction",
|
||||
}
|
||||
# "this" alone is deixis without a referent — only NAMED-scope phrases
|
||||
# ("the module", "this project", ...) or an explicit target_project bind
|
||||
# scope. Bare "this" contributes nothing (Tranche 10 §4).
|
||||
_SCOPING_WORDS = {
|
||||
"existing",
|
||||
"the module",
|
||||
"the project",
|
||||
"the app",
|
||||
"the service",
|
||||
"current",
|
||||
"that module",
|
||||
"the codebase",
|
||||
"this module",
|
||||
"this project",
|
||||
"this app",
|
||||
}
|
||||
_CONSTRUCTION_WORDS = {
|
||||
"build",
|
||||
"create",
|
||||
"make",
|
||||
"implement",
|
||||
"add",
|
||||
"write",
|
||||
"develop",
|
||||
"construct",
|
||||
"fix",
|
||||
"repair",
|
||||
"refactor",
|
||||
"migrate",
|
||||
"change",
|
||||
"extend",
|
||||
"replace",
|
||||
"feature",
|
||||
"export",
|
||||
"module",
|
||||
"service",
|
||||
"app",
|
||||
"tool",
|
||||
"component",
|
||||
"schema",
|
||||
"api",
|
||||
}
|
||||
_NOT_CONSTRUCTION_HINTS = {
|
||||
"explain",
|
||||
"what is",
|
||||
"how does",
|
||||
"why does",
|
||||
"list",
|
||||
"confidence",
|
||||
"improvisation",
|
||||
"summarize",
|
||||
"describe",
|
||||
"teach me",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConstructionMode:
|
||||
"""Typed classification decision with full evidence provenance."""
|
||||
|
||||
mode: str
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
target_project: str | None = None
|
||||
project_authority: str | None = None # e.g. "rune_internal" | scaffold id
|
||||
confidence: float = 0.0
|
||||
clarification_required: bool = False
|
||||
probe_questions: list[str] = field(default_factory=list)
|
||||
bootstrap_required: bool = False
|
||||
override: dict | None = None # provenance if manually forced
|
||||
proposed_by_model: dict | None = None # recorded, never authority
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"evidence": self.evidence,
|
||||
"target_project": self.target_project,
|
||||
"project_authority": self.project_authority,
|
||||
"confidence": self.confidence,
|
||||
"clarification_required": self.clarification_required,
|
||||
"probe_questions": self.probe_questions,
|
||||
"bootstrap_required": self.bootstrap_required,
|
||||
"override": self.override,
|
||||
"proposed_by_model": self.proposed_by_model,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ConstructionMode":
|
||||
known = {
|
||||
k: d[k]
|
||||
for k in (
|
||||
"mode",
|
||||
"evidence",
|
||||
"target_project",
|
||||
"project_authority",
|
||||
"confidence",
|
||||
"clarification_required",
|
||||
"probe_questions",
|
||||
"bootstrap_required",
|
||||
"override",
|
||||
"proposed_by_model",
|
||||
)
|
||||
if k in d
|
||||
}
|
||||
return cls(**known)
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
return set(re.split(r"[^a-z0-9]+", text.lower()))
|
||||
|
||||
|
||||
def _contains_any(text: str, phrases) -> list[str]:
|
||||
t = text.lower()
|
||||
return [p for p in phrases if p in t]
|
||||
|
||||
|
||||
def classify_mode(
|
||||
objective: str,
|
||||
*,
|
||||
target_project: str | None = None,
|
||||
project_exists: bool | None = None,
|
||||
affected_surface: list[str] | None = None,
|
||||
explicit_override: str | None = None,
|
||||
override_authority: str | None = None,
|
||||
model_proposal: dict | None = None,
|
||||
) -> ConstructionMode:
|
||||
"""Classify the construction mode for one intent (§4-6).
|
||||
|
||||
`project_exists` — structural context: whether a Project Scaffold /
|
||||
project binding already exists for the target. Project existence
|
||||
MATERIALLY affects classification (§5/§20): the same request resolves
|
||||
differently with and without an existing project.
|
||||
|
||||
`explicit_override` — operator/builder authority feed (§11/§23):
|
||||
validated against known modes; provenance recorded. Override feeds the
|
||||
SAME machinery — it never bypasses the evidence record.
|
||||
"""
|
||||
ev: list[str] = []
|
||||
obj = objective or ""
|
||||
mode = None
|
||||
conf = 0.0
|
||||
bootstrap = False
|
||||
clarification = False
|
||||
probes: list[str] = []
|
||||
|
||||
# 1. explicit override (§23): authority-validated, provenance-carrying
|
||||
if explicit_override is not None:
|
||||
if explicit_override not in MODES:
|
||||
raise ValueError(f"override must be one of {MODES}, got {explicit_override!r}")
|
||||
ev.append(f"explicit override by {override_authority or 'operator'}")
|
||||
m = ConstructionMode(
|
||||
mode=explicit_override,
|
||||
evidence=ev,
|
||||
target_project=target_project,
|
||||
confidence=1.0,
|
||||
bootstrap_required=(explicit_override == "PROJECT_BUILD"),
|
||||
override={"authority": override_authority or "operator", "from_mode": None},
|
||||
)
|
||||
if explicit_override == "UNKNOWN":
|
||||
m.clarification_required = True
|
||||
return m
|
||||
|
||||
# 2. NOT_CONSTRUCTION: question/list/learning intents with no build verbs
|
||||
notc = _contains_any(obj.lower(), _NOT_CONSTRUCTION_HINTS)
|
||||
has_verb = bool(_tokens(obj) & _CONSTRUCTION_WORDS)
|
||||
if notc:
|
||||
# verb-object check: a construction verb whose object is a
|
||||
# non-constructible noun (list/confidence/explanation) is not a
|
||||
# construction request (§4: "build confidence", "make a list")
|
||||
non_constructible = {"list", "confidence", "explanation", "summary", "chord", "note"}
|
||||
obj_tokens = _tokens(obj)
|
||||
standalone_evidence = bool(_contains_any(obj.lower(), _STANDALONE_WORDS))
|
||||
if not has_verb or (obj_tokens & non_constructible and not standalone_evidence):
|
||||
ev.append(f"non-construction intent markers: {notc[:3]}")
|
||||
ev.append(
|
||||
"construction-verb object is not a buildable artifact" if has_verb else "no construction verb present"
|
||||
)
|
||||
return ConstructionMode(mode="NOT_CONSTRUCTION", evidence=ev, confidence=0.85)
|
||||
|
||||
if not obj.strip():
|
||||
ev.append("empty objective")
|
||||
return ConstructionMode(
|
||||
mode="UNKNOWN",
|
||||
evidence=ev,
|
||||
confidence=0.0,
|
||||
clarification_required=True,
|
||||
probe_questions=["what should be built?"],
|
||||
)
|
||||
|
||||
standalone = _contains_any(obj.lower(), _STANDALONE_WORDS)
|
||||
local = _contains_any(obj.lower(), _LOCAL_WORDS)
|
||||
scoped = _contains_any(obj.lower(), _SCOPING_WORDS)
|
||||
# T15 §15J: an explicit FILE mention binds a scoped target ("fix the
|
||||
# typo in the README", "update scheduler.js"). Deterministic lexical
|
||||
# evidence in the same corroboration style as the other lexicons;
|
||||
# dotted/path tokens delegated to the ONE surface authority
|
||||
# (construction_decision.classify_surface) — lazily imported (cycle:
|
||||
# construction_decision imports this module's classify_mode).
|
||||
file_scoped = False
|
||||
for w in re.split(r"[^a-z0-9_./-]+", obj.lower()):
|
||||
if not w or len(w) < 3:
|
||||
continue
|
||||
if w in _KNOWN_FILES or "." in w or "/" in w:
|
||||
try:
|
||||
from rune.construction_decision import classify_surface
|
||||
if classify_surface(w) in ("source_path", "workflow_config"):
|
||||
file_scoped = True
|
||||
break
|
||||
except Exception: # noqa: BLE001,S110 — authority unavailable:
|
||||
pass # lexical fallback decides; never fatal
|
||||
if w in _KNOWN_FILES:
|
||||
file_scoped = True
|
||||
break
|
||||
|
||||
# 3. PROJECT_BUILD: standalone/new-lifecycle language AND no existing
|
||||
# project for the target (project existence materially matters, §5)
|
||||
if standalone:
|
||||
ev.append(f"standalone/new-lifecycle language: {standalone[:3]}")
|
||||
if project_exists is True:
|
||||
# §5: existing project preferred unless evidence says otherwise —
|
||||
# contradictory evidence → UNKNOWN, never silent choice
|
||||
ev.append("BUT target project already exists (structural evidence contradicts standalone language)")
|
||||
mode, conf, clarification = "UNKNOWN", 0.3, True
|
||||
probes.append("should this extend the existing project or start a genuinely separate one?")
|
||||
elif project_exists is None:
|
||||
ev.append("no project-existence evidence for the target")
|
||||
mode, conf, clarification = "UNKNOWN", 0.4, True
|
||||
probes.append("does a project for this already exist?")
|
||||
else:
|
||||
mode, conf = "PROJECT_BUILD", 0.9
|
||||
bootstrap = True
|
||||
# 4. LOCAL_CHANGE: explicit smallness + scoped target + no standalone
|
||||
elif local and (scoped or file_scoped or target_project or project_exists):
|
||||
ev.append(f"smallness markers: {local[:3]}")
|
||||
if scoped:
|
||||
ev.append(f"scoping words bind to known target: {scoped[:3]}")
|
||||
if file_scoped:
|
||||
ev.append("file mention binds scoped target")
|
||||
mode, conf = "LOCAL_CHANGE", 0.8
|
||||
# 5. EXISTING_PROJECT_CHANGE: scoping to a known/existing target.
|
||||
# T11 §1: a change scoped to a project with PROVEN non-existence must
|
||||
# NOT silently claim existing-project semantics — clarify instead.
|
||||
elif (scoped or target_project or project_exists is True) and has_verb:
|
||||
ev.append("change scoped to an existing project/target")
|
||||
if target_project:
|
||||
ev.append(f"target project: {target_project}")
|
||||
if project_exists is False:
|
||||
ev.append(
|
||||
"BUT structural evidence proves the target project "
|
||||
"does not exist — refusing to manufacture existing-"
|
||||
"project semantics"
|
||||
)
|
||||
mode, conf, clarification = "UNKNOWN", 0.3, True
|
||||
probes.append(
|
||||
f"project '{target_project or 'the target'}' has "
|
||||
"no structural record — is it new (project "
|
||||
"build) or does it live somewhere not bound?"
|
||||
)
|
||||
else:
|
||||
mode, conf = "EXISTING_PROJECT_CHANGE", 0.8
|
||||
# 6. construction verb but no scope and no standalone language → UNKNOWN
|
||||
elif has_verb:
|
||||
ev.append("construction verb present but no target-scope evidence and no standalone/new-lifecycle language")
|
||||
mode, conf, clarification = "UNKNOWN", 0.35, True
|
||||
probes.append("is this a change to an existing project or a new standalone one?")
|
||||
probes.append("which project/module is the target?")
|
||||
else:
|
||||
ev.append("insufficient lexical evidence either way")
|
||||
mode, conf, clarification = "UNKNOWN", 0.2, True
|
||||
probes.append("is this a construction request?")
|
||||
|
||||
if affected_surface and mode in ("LOCAL_CHANGE", "EXISTING_PROJECT_CHANGE", "UNKNOWN"):
|
||||
ev.append(f"affected surface known: {affected_surface[:2]}")
|
||||
if mode == "UNKNOWN":
|
||||
mode, conf = "EXISTING_PROJECT_CHANGE", 0.6
|
||||
clarification = False
|
||||
|
||||
# §5 model proposal: recorded, never authority
|
||||
proposed = model_proposal if model_proposal else None
|
||||
|
||||
return ConstructionMode(
|
||||
mode=mode,
|
||||
evidence=ev,
|
||||
target_project=target_project,
|
||||
project_authority=("rune_internal" if target_project in (None, "rune", "issue161-harness") else target_project),
|
||||
confidence=conf,
|
||||
clarification_required=clarification,
|
||||
probe_questions=probes,
|
||||
bootstrap_required=(mode == "PROJECT_BUILD"),
|
||||
proposed_by_model=proposed,
|
||||
)
|
||||
@@ -0,0 +1,496 @@
|
||||
"""construction_pattern.py — reusable evidence about HOW a class of thing
|
||||
is normally built.
|
||||
|
||||
SCAFFOLD-TRANCHE-006. Canonical distinction (tranche brief):
|
||||
|
||||
Construction Pattern = reusable knowledge for a CLASS of construction
|
||||
Project Scaffold = structural truth for THIS project
|
||||
Construction Contract = obligations for THIS change
|
||||
|
||||
A pattern is GUIDANCE, not authority (§3): project evidence always wins.
|
||||
A pattern must never overwrite contradictory project evidence — if the
|
||||
pattern assumes HTTP services and the project evidence says message-queue-
|
||||
only, the pattern adapts or becomes non-applicable.
|
||||
|
||||
Applicability (§4-5) is EVIDENCE-BACKED and deterministic: signals are
|
||||
checked against the affected surface + structural authorities. Verdicts:
|
||||
APPLIES / PARTIAL / DOES_NOT_APPLY / UNKNOWN. UNKNOWN never becomes APPLIES
|
||||
because a name sounds similar; name similarity establishes nothing. Model
|
||||
reasoning may PROPOSE a pattern (recorded, PENDING) — it may not certify
|
||||
applicability.
|
||||
|
||||
Obligation derivation (§6-7) is FILTERED by this project: every pattern
|
||||
obligation carries a `when` condition evaluated against project evidence;
|
||||
verdicts REQUIRED / APPLICABLE / NOT_APPLICABLE / UNKNOWN with the reason.
|
||||
Patterns REDUCE false-positive obligation expansion, never amplify it.
|
||||
|
||||
Provenance (§16): every rule answers "why is this here" with real Rune
|
||||
evidence — incidents, contracts, tests, user requirements. Patterns
|
||||
accumulate verified engineering knowledge, not model folklore.
|
||||
|
||||
Revision (§17): lightweight revision identity; contracts record the
|
||||
revision consumed so "which builds used the old rule" stays answerable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune import structural_store as ss
|
||||
|
||||
__all__ = [
|
||||
"ConstructionPattern",
|
||||
"evaluate_applicability",
|
||||
"derive_pattern_obligations",
|
||||
"record_pattern_proposal",
|
||||
"APPLIES",
|
||||
"PARTIAL",
|
||||
"DOES_NOT_APPLY",
|
||||
"UNKNOWN",
|
||||
"OBL_REQUIRED",
|
||||
"OBL_APPLICABLE",
|
||||
"OBL_NOT_APPLICABLE",
|
||||
"OBL_UNKNOWN",
|
||||
]
|
||||
|
||||
APPLIES = "APPLIES"
|
||||
PARTIAL = "PARTIAL"
|
||||
DOES_NOT_APPLY = "DOES_NOT_APPLY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
OBL_REQUIRED = "REQUIRED"
|
||||
OBL_APPLICABLE = "APPLICABLE"
|
||||
OBL_NOT_APPLICABLE = "NOT_APPLICABLE"
|
||||
OBL_UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
# ── pattern object (open-world) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConstructionPattern:
|
||||
"""One reusable construction pattern. Open-world: `ext` carries anything
|
||||
the schema above does not name. NOT structural truth — guidance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pattern_id: str,
|
||||
purpose: str,
|
||||
*,
|
||||
revision: int = 1,
|
||||
maturity: str = "CANDIDATE",
|
||||
signals: dict | None = None,
|
||||
obligations: list[dict] | None = None,
|
||||
provenance: list[str] | None = None,
|
||||
exclusions: list[str] | None = None,
|
||||
toolchain_notes: list[str] | None = None,
|
||||
runtime_effects: list[str] | None = None,
|
||||
**ext,
|
||||
):
|
||||
if not pattern_id:
|
||||
raise ValueError("pattern_id required")
|
||||
if not purpose:
|
||||
raise ValueError("purpose required")
|
||||
for o in obligations or []:
|
||||
for key in ("id", "obligation", "when"):
|
||||
if key not in o:
|
||||
raise ValueError(f"obligation missing {key!r}: {o}")
|
||||
self.pattern_id = pattern_id
|
||||
self.purpose = purpose
|
||||
self.revision = revision
|
||||
self.maturity = maturity
|
||||
self.signals = dict(signals or {})
|
||||
self.obligations = list(obligations or [])
|
||||
self.provenance = sorted(provenance or [])
|
||||
self.exclusions = sorted(exclusions or [])
|
||||
self.toolchain_notes = sorted(toolchain_notes or [])
|
||||
self.runtime_effects = sorted(runtime_effects or [])
|
||||
self.ext = dict(ext)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"pattern_id": self.pattern_id,
|
||||
"purpose": self.purpose,
|
||||
"revision": self.revision,
|
||||
"maturity": self.maturity,
|
||||
"signals": dict(self.signals),
|
||||
"obligations": [dict(o) for o in self.obligations],
|
||||
"provenance": list(self.provenance),
|
||||
"exclusions": list(self.exclusions),
|
||||
"toolchain_notes": list(self.toolchain_notes),
|
||||
"runtime_effects": list(self.runtime_effects),
|
||||
**self.ext,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ConstructionPattern":
|
||||
src = dict(d) # never mutate the caller's dict
|
||||
known = {
|
||||
k: src.pop(k)
|
||||
for k in (
|
||||
"pattern_id",
|
||||
"purpose",
|
||||
"revision",
|
||||
"maturity",
|
||||
"signals",
|
||||
"obligations",
|
||||
"provenance",
|
||||
"exclusions",
|
||||
"toolchain_notes",
|
||||
"runtime_effects",
|
||||
)
|
||||
if k in src
|
||||
}
|
||||
return cls(**known, **src)
|
||||
|
||||
def revise(self, *, reason: str, evidence: list[str], changes: dict | None = None) -> "ConstructionPattern":
|
||||
"""Revision with provenance (§17): prior meaning not invisibly
|
||||
mutated — returns a NEW pattern at revision+1 recording the change."""
|
||||
d = self.to_dict()
|
||||
d.pop("pattern_id")
|
||||
rev = {**d, **(changes or {})}
|
||||
rev["revision"] = self.revision + 1
|
||||
rev.setdefault("revision_history", []).append(
|
||||
{
|
||||
"prior_revision": self.revision,
|
||||
"reason": reason[:200],
|
||||
"evidence": sorted(evidence),
|
||||
"changed": sorted((changes or {}).keys()),
|
||||
}
|
||||
)
|
||||
return ConstructionPattern(self.pattern_id, **rev)
|
||||
|
||||
|
||||
# ── model proposal seam (§5 — recorded, never authoritative) ────────────────
|
||||
|
||||
|
||||
def record_pattern_proposal(
|
||||
pattern_id: str, *, provider: str, model: str, evidence: list[str], confidence: float
|
||||
) -> dict:
|
||||
"""A model may PROPOSE a pattern; it may not certify applicability.
|
||||
Recorded with verification state PENDING until deterministic project
|
||||
evidence, research evidence, proven construction evidence, or an
|
||||
explicit user decision accepts it."""
|
||||
if not provider or not model:
|
||||
raise ValueError("pattern proposal requires provider+model")
|
||||
if not 0.0 <= confidence <= 1.0:
|
||||
raise ValueError("confidence must be in [0,1]")
|
||||
return {
|
||||
"kind": "pattern_proposal",
|
||||
"pattern_id": pattern_id,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"evidence": sorted(evidence),
|
||||
"confidence": confidence,
|
||||
"verification_state": "PENDING",
|
||||
}
|
||||
|
||||
|
||||
# ── applicability (§4 — deterministic signals only) ─────────────────────────
|
||||
|
||||
|
||||
def _surface_text(need, surface_result: dict) -> set[str]:
|
||||
"""Everything the affected surface offers as evidence: paths, entities,
|
||||
ui surfaces, regions, workflow configs, the objective text tokens."""
|
||||
tokens = set()
|
||||
for k in (
|
||||
"affected_paths",
|
||||
"affected_existing_entities",
|
||||
"affected_ui_surfaces",
|
||||
"affected_regions",
|
||||
"workflow_config_surfaces",
|
||||
):
|
||||
tokens.update(surface_result.get(k, []))
|
||||
tokens.update(need.affected_surface)
|
||||
# objective word tokens (deterministic split — not semantics)
|
||||
tokens.update(w.lower() for w in need.objective.replace("/", " ").replace("-", " ").split())
|
||||
return tokens
|
||||
|
||||
|
||||
# ── shared boundary-aware matchers (Tranche 7 §0) ──────────────────────────
|
||||
|
||||
def _path_matches(signal: str, path: str) -> bool:
|
||||
"""Boundary-aware path-signal matching: short (<4 char) signals match
|
||||
path TOKENS only (the 'ui'-in-'builtin' false-positive class); longer
|
||||
signals may use containment."""
|
||||
import re as _re
|
||||
|
||||
s = signal.lower()
|
||||
if len(s) >= 4:
|
||||
return s in path.lower()
|
||||
return s in set(_re.split(r"[^a-z0-9]+", path.lower()))
|
||||
|
||||
|
||||
def _entity_matches(signal: str, entity_id: str) -> bool:
|
||||
"""Same discipline for entity ids. Prefix signals ending in ':' (e.g.
|
||||
'ui:', 'region:') match the id's namespace prefix — structural kinds
|
||||
carry meaning in their prefix, and prefix equality is exact, not a
|
||||
substring accident."""
|
||||
import re as _re
|
||||
|
||||
s = signal.lower()
|
||||
if s.endswith(":"):
|
||||
return entity_id.lower().startswith(s)
|
||||
if len(s) >= 4:
|
||||
return s in entity_id.lower()
|
||||
return s in set(_re.split(r"[^a-z0-9:]+", entity_id.lower()))
|
||||
|
||||
|
||||
def evaluate_applicability(
|
||||
pattern: ConstructionPattern, need, surface_result: dict, authorities: dict[str, dict]
|
||||
) -> dict:
|
||||
"""Deterministic applicability verdict from the pattern's declared
|
||||
signals (§4). Signals (all optional; evidence-checked against the
|
||||
affected surface and structural authorities):
|
||||
surface_kinds subset of {source_path, workflow_config,
|
||||
ui_component, layout_region,
|
||||
structural_entity}
|
||||
path_contains any-of substrings expected in affected paths
|
||||
entities_match any-of entity-id prefixes/substrings
|
||||
objective_any any-of lowercase words expected in the objective
|
||||
contracts_match any-of contract ids implemented by affected entities
|
||||
|
||||
Verdict: count how many DECLARED signal groups produce evidence.
|
||||
all declared groups hit -> APPLIES
|
||||
some hit -> PARTIAL (or DOES_NOT_APPLY when the
|
||||
pattern's `strong` groups all miss)
|
||||
none / no signals -> UNKNOWN (never APPLIES by name)
|
||||
Project evidence overrides (§3): exclusions evaluated first — if the
|
||||
need explicitly declares the pattern non-applicable, DOES_NOT_APPLY
|
||||
with the exclusion recorded.
|
||||
"""
|
||||
# §3/§18: PROJECT evidence overrides the pattern. Exclusions may be
|
||||
# declared by the project/need (pattern_exclusions constraint) or by the
|
||||
# pattern itself (non-applicability domains).
|
||||
declared_exclusions = list(pattern.exclusions) + list(need.constraints.get("pattern_exclusions", []))
|
||||
for ex in declared_exclusions:
|
||||
if ex.lower() in need.objective.lower() or ex in need.constraints.get("pattern_exclusions", []):
|
||||
return {
|
||||
"pattern": pattern.pattern_id,
|
||||
"verdict": DOES_NOT_APPLY,
|
||||
"reason": f"declared exclusion matched: {ex}",
|
||||
"evidence": {"exclusion": ex, "source": "project/need declaration"},
|
||||
}
|
||||
|
||||
signals = pattern.signals
|
||||
if not signals:
|
||||
return {
|
||||
"pattern": pattern.pattern_id,
|
||||
"verdict": UNKNOWN,
|
||||
"reason": "pattern declares no applicability signals — nothing deterministic to check",
|
||||
"evidence": {},
|
||||
}
|
||||
tokens = _surface_text(need, surface_result)
|
||||
|
||||
groups_hit, groups_declared = [], 0
|
||||
evidence: dict = {}
|
||||
|
||||
def _check(group: str, candidates: list[str], present) -> None:
|
||||
nonlocal groups_declared
|
||||
if not candidates:
|
||||
return
|
||||
groups_declared += 1
|
||||
hits = [c for c in candidates if present(c)]
|
||||
if hits:
|
||||
groups_hit.append(group)
|
||||
evidence[group] = hits[:5]
|
||||
|
||||
_check(
|
||||
"surface_kinds",
|
||||
signals.get("surface_kinds", []),
|
||||
lambda c: (
|
||||
bool(
|
||||
surface_result.get(
|
||||
{
|
||||
"source_path": "affected_paths",
|
||||
"workflow_config": "workflow_config_surfaces",
|
||||
"ui_component": "affected_ui_surfaces",
|
||||
"layout_region": "affected_regions",
|
||||
}.get(c, ""),
|
||||
[],
|
||||
)
|
||||
)
|
||||
or any(r["kind"] == c for r in surface_result.get("resolutions", []))
|
||||
),
|
||||
)
|
||||
_check(
|
||||
"path_contains",
|
||||
[p for p in signals.get("path_contains", [])],
|
||||
lambda c: any(
|
||||
_path_matches(c, p)
|
||||
for p in surface_result.get("affected_paths", []) + surface_result.get("workflow_config_surfaces", [])
|
||||
),
|
||||
)
|
||||
_check(
|
||||
"entities_match",
|
||||
[e for e in signals.get("entities_match", [])],
|
||||
lambda c: any(
|
||||
_entity_matches(c, e) for e in surface_result.get("affected_existing_entities", [])
|
||||
),
|
||||
)
|
||||
_check(
|
||||
"objective_any",
|
||||
[w.lower() for w in signals.get("objective_any", [])],
|
||||
lambda c: c in {w.lower() for w in need.objective.replace("/", " ").split()},
|
||||
)
|
||||
contracts_match = signals.get("contracts_match", [])
|
||||
if contracts_match:
|
||||
groups_declared += 1
|
||||
hits = []
|
||||
for name, doc in authorities.items():
|
||||
for e in surface_result.get("affected_existing_entities", []):
|
||||
for r in ss.relationships(doc, from_id=e):
|
||||
if r["type"] in {"implements", "exposes", "provides", "serves"} and r["to"] in contracts_match:
|
||||
hits.append(f"{e}->{r['to']}")
|
||||
if hits:
|
||||
groups_hit.append("contracts_match")
|
||||
evidence["contracts_match"] = hits[:5]
|
||||
|
||||
if groups_declared == 0:
|
||||
return {
|
||||
"pattern": pattern.pattern_id,
|
||||
"verdict": UNKNOWN,
|
||||
"reason": "no applicable signal groups declared",
|
||||
"evidence": {},
|
||||
}
|
||||
if len(groups_hit) == groups_declared:
|
||||
verdict = APPLIES
|
||||
elif groups_hit:
|
||||
verdict = PARTIAL
|
||||
else:
|
||||
verdict = DOES_NOT_APPLY
|
||||
return {
|
||||
"pattern": pattern.pattern_id,
|
||||
"verdict": verdict,
|
||||
"reason": f"{len(groups_hit)}/{groups_declared} signal groups "
|
||||
f"produced evidence: {sorted(groups_hit) or 'none'}",
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
# ── obligation derivation (§6-7 — filtered by project evidence) ─────────────
|
||||
|
||||
|
||||
def _obligation_when_hit(obligation: dict, need, surface_result: dict, authorities: dict[str, dict]) -> tuple[str, str]:
|
||||
"""Evaluate one obligation's `when` condition against project evidence.
|
||||
Returns (verdict, reason). `when` shapes (all deterministic):
|
||||
path_contains: [substrings] — any affected/config path matches
|
||||
entities_match: [substrings] — any affected entity matches
|
||||
always: true — applies whenever the pattern applies
|
||||
contracts_touched: [ids] — contract in requirement or implemented
|
||||
by affected entities
|
||||
An obligation with no recognized `when` is UNKNOWN (never silently
|
||||
required)."""
|
||||
when = obligation.get("when")
|
||||
paths = [p for p in surface_result.get("affected_paths", [])] + [
|
||||
p for p in surface_result.get("workflow_config_surfaces", [])
|
||||
]
|
||||
entities = (
|
||||
[e for e in surface_result.get("affected_existing_entities", [])]
|
||||
+ [u for u in surface_result.get("affected_ui_surfaces", [])]
|
||||
+ [r for r in surface_result.get("affected_regions", [])]
|
||||
)
|
||||
if when is None:
|
||||
return OBL_UNKNOWN, "obligation declares no applicability condition"
|
||||
if when.get("always") is True:
|
||||
return OBL_REQUIRED, "pattern-level obligation (always applies when the pattern applies)"
|
||||
if "path_contains" in when:
|
||||
hits = [p for sub in when["path_contains"] for p in paths if _path_matches(sub, p)]
|
||||
if hits:
|
||||
return OBL_APPLICABLE, f"affected path(s): {hits[:3]}"
|
||||
return OBL_NOT_APPLICABLE, ("no affected path matches " + str(when["path_contains"]))
|
||||
if "entities_match" in when:
|
||||
hits = [e for sub in when["entities_match"] for e in entities if _entity_matches(sub, e)]
|
||||
if hits:
|
||||
return OBL_APPLICABLE, f"affected entity/entities: {hits[:3]}"
|
||||
return OBL_NOT_APPLICABLE, ("no affected entity matches " + str(when["entities_match"]))
|
||||
if "contracts_touched" in when:
|
||||
touched = set(need.constraints.get("contracts", []))
|
||||
for name, doc in authorities.items():
|
||||
for e in entities:
|
||||
for r in ss.relationships(doc, from_id=e):
|
||||
if r["type"] in {"implements", "exposes", "provides", "serves"}:
|
||||
touched.add(r["to"])
|
||||
hits = [c for c in when["contracts_touched"] if c in touched]
|
||||
if hits:
|
||||
return OBL_APPLICABLE, f"contract(s) touched: {hits}"
|
||||
return OBL_NOT_APPLICABLE, ("touched contracts do not include " + str(when["contracts_touched"]))
|
||||
return OBL_UNKNOWN, f"unrecognized when-condition shape: {sorted(when)}"
|
||||
|
||||
|
||||
def derive_pattern_obligations(
|
||||
pattern: ConstructionPattern,
|
||||
need,
|
||||
surface_result: dict,
|
||||
authorities: dict[str, dict],
|
||||
project_exceptions: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Derive this pattern's obligations FILTERED by project evidence (§7).
|
||||
Every returned obligation carries: pattern, revision, obligation text,
|
||||
verdict, reason/evidence, and provenance kind PATTERN (distinct from
|
||||
Tranche 5's STRUCTURAL derivation — §8 provenance separation).
|
||||
Project exceptions (§18) suppress with provenance retained."""
|
||||
project_exceptions = list(project_exceptions or [])
|
||||
out = {
|
||||
"pattern": pattern.pattern_id,
|
||||
"revision": pattern.revision,
|
||||
"accepted": [],
|
||||
"rejected": [],
|
||||
"unresolved": [],
|
||||
}
|
||||
for o in pattern.obligations:
|
||||
entry = {
|
||||
"id": o["id"],
|
||||
"obligation": o["obligation"],
|
||||
"pattern": pattern.pattern_id,
|
||||
"revision": pattern.revision,
|
||||
"provenance": "PATTERN",
|
||||
"pattern_provenance": list(pattern.provenance),
|
||||
}
|
||||
# project exception check first (§18) — suppress with provenance
|
||||
exc = next((x for x in project_exceptions if x.get("obligation") == o["id"]), None)
|
||||
if exc:
|
||||
entry.update(
|
||||
{
|
||||
"verdict": "EXCEPTED",
|
||||
"reason": exc.get("reason", ""),
|
||||
"exception_evidence": exc.get("evidence", ""),
|
||||
"exception_authority": exc.get("authority", "user"),
|
||||
}
|
||||
)
|
||||
out["rejected"].append(entry)
|
||||
continue
|
||||
verdict, reason = _obligation_when_hit(o, need, surface_result, authorities)
|
||||
entry["verdict"] = verdict
|
||||
entry["reason"] = reason
|
||||
if verdict in (OBL_REQUIRED, OBL_APPLICABLE):
|
||||
out["accepted"].append(entry)
|
||||
elif verdict == OBL_NOT_APPLICABLE:
|
||||
out["rejected"].append(entry)
|
||||
else:
|
||||
out["unresolved"].append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def combine_pattern_obligations(derivations: list[dict]) -> dict:
|
||||
"""Combine multiple patterns' derivations (§26-27): duplicates of the
|
||||
same obligation id collapse (provenance lists merge); conflicting
|
||||
obligations (same id, contradictory verdicts across patterns) surface
|
||||
as conflicts requiring evidence/user decision — never silently chosen."""
|
||||
merged: dict[str, dict] = {}
|
||||
conflicts: list[dict] = []
|
||||
for d in derivations:
|
||||
for entry in d["accepted"]:
|
||||
oid = entry["id"]
|
||||
if oid in merged:
|
||||
prev = merged[oid]
|
||||
if prev["obligation"] != entry["obligation"]:
|
||||
conflicts.append(
|
||||
{
|
||||
"obligation_id": oid,
|
||||
"conflict": "same id, different text",
|
||||
"sources": [prev["pattern"], entry["pattern"]],
|
||||
}
|
||||
)
|
||||
continue
|
||||
prev.setdefault("also_from", []).append(entry["pattern"])
|
||||
continue
|
||||
merged[oid] = dict(entry)
|
||||
return {"obligations": sorted(merged.values(), key=lambda x: x["id"]), "conflicts": conflicts}
|
||||
@@ -0,0 +1,249 @@
|
||||
"""construction_patterns_builtin.py — the initial evidence-grounded pattern
|
||||
set (SCAFFOLD-TRANCHE-006 §10-14: 4 patterns, each derived from REAL Rune
|
||||
evidence, each appropriately immature).
|
||||
|
||||
This module is DATA, not a framework: pattern provenance answers "why is
|
||||
this rule here" with real incidents/contracts/tests. Maturity stays
|
||||
CANDIDATE/UNIT-PROVEN — existence is not trust (§15).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune.construction_pattern import ConstructionPattern
|
||||
|
||||
__all__ = ["BUILTIN_PATTERNS", "get_pattern", "PATTERN_REGISTRY_REVISION"]
|
||||
|
||||
# Bumped when any builtin pattern's obligations change meaningfully.
|
||||
PATTERN_REGISTRY_REVISION = 1
|
||||
|
||||
CAPABILITY_ADAPTATION = ConstructionPattern(
|
||||
"capability-module-adaptation",
|
||||
"Adapting or extending an existing capability/module: prove the NEW "
|
||||
"behavior, preserve the OLD behavior (BUILD BUT DON'T BREAK).",
|
||||
maturity="UNIT-PROVEN",
|
||||
signals={
|
||||
"entities_match": ["module:", "capability:"],
|
||||
"path_contains": ["src/"],
|
||||
"objective_any": ["adapt", "extend", "modify", "preserve", "existing", "capability", "repair"],
|
||||
},
|
||||
obligations=[
|
||||
{
|
||||
"id": "adapt-new-behavior-proof",
|
||||
"obligation": "new behavior proven by tests before closure",
|
||||
"when": {"always": True},
|
||||
},
|
||||
{
|
||||
"id": "adapt-old-behavior-preserved",
|
||||
"obligation": "existing capability behavior preserved; original contracts remain valid",
|
||||
"when": {"always": True},
|
||||
},
|
||||
{
|
||||
"id": "adapt-consumers-verified",
|
||||
"obligation": "existing consumers of the adapted capability verified against their contracts",
|
||||
"when": {"entities_match": ["module:", "capability:"]},
|
||||
},
|
||||
{
|
||||
"id": "adapt-migration-detected",
|
||||
"obligation": "migration obligations detected and recorded if any consumer-visible interface changes",
|
||||
"when": {"path_contains": ["src/"]},
|
||||
},
|
||||
],
|
||||
provenance=[
|
||||
"SCAFFOLD-TRANCHE-001 (build-but-don't-break established)",
|
||||
"SCAFFOLD-TRANCHE-003 (adaptation two-proof rule)",
|
||||
"construction_decision.obligations_from_trace (auto-derived since T4)",
|
||||
"LIVE-CONTRACT-QUALITY-001 (adaptation resolved + closed live)",
|
||||
],
|
||||
)
|
||||
|
||||
RELEASE_DEPLOYMENT = ConstructionPattern(
|
||||
"release-deployment-change",
|
||||
"Changes to the release/deployment path: fail-closed qualification, "
|
||||
"exact candidate identity, staging != promotion, truthful promotion "
|
||||
"outcomes, deployed-identity verification, negative + forward proofs.",
|
||||
maturity="UNIT-PROVEN",
|
||||
signals={
|
||||
"path_contains": [
|
||||
"deploy",
|
||||
"promotion",
|
||||
"qualify",
|
||||
"release.yml",
|
||||
"ci.yml",
|
||||
"deploy_qualify",
|
||||
"deploy_stage",
|
||||
"deploy_promotion",
|
||||
],
|
||||
"objective_any": ["deploy", "release", "promot", "qualif", "candidate", "current", "monotonic"],
|
||||
"workflow_config_signals": True, # informational; surface kinds carry it
|
||||
},
|
||||
obligations=[
|
||||
{
|
||||
"id": "rel-fail-closed-qualification",
|
||||
"obligation": "CI qualification remains fail-closed (exact SHA, "
|
||||
"two-leg verdict); red candidates never deploy",
|
||||
"when": {"path_contains": ["deploy", "release", "qualify", "ci.yml"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-exact-candidate-identity",
|
||||
"obligation": "deployment addresses the EXACT pushed revision, never whatever HEAD is at deploy time",
|
||||
"when": {"path_contains": ["deploy", "release"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-staging-promotion-distinction",
|
||||
"obligation": "staged candidate != promoted candidate; promotion outcome must be independently verifiable",
|
||||
"when": {"path_contains": ["deploy", "promotion", "stage"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-promotion-truthfulness",
|
||||
"obligation": "promotion outcomes are truthful (PROMOTED / "
|
||||
"NOOP_ALREADY_CURRENT / STALE_SUPERSEDED / REFUSED_*); "
|
||||
"stale candidates never claim success",
|
||||
"when": {"path_contains": ["promotion", "deploy_stage", "deploy_promotion"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-monotonic-promotion",
|
||||
"obligation": "promotion ordering: an older candidate finishing "
|
||||
"later cannot regress current; ancestry check + "
|
||||
"fenced critical section",
|
||||
"when": {"path_contains": ["deploy_promotion", "deploy_stage", "promotion"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-negative-candidate-proof",
|
||||
"obligation": "negative proof: red/unqualified candidate is refused; stale candidate cannot promote",
|
||||
"when": {"path_contains": ["deploy", "promotion", "qualify"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-forward-candidate-proof",
|
||||
"obligation": "forward proof: a green newer candidate still promotes normally",
|
||||
"when": {"path_contains": ["deploy", "promotion"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-deployed-identity-verification",
|
||||
"obligation": "deployed identity verified from the deployment "
|
||||
"authority (current symlink + build identity), not "
|
||||
"from job artifacts alone",
|
||||
"when": {"always": True},
|
||||
},
|
||||
{
|
||||
"id": "rel-release-regression-verification",
|
||||
"obligation": "existing release-safety guarantees re-proven (the "
|
||||
"guarantees that already exist must not silently "
|
||||
"weaken)",
|
||||
"when": {"always": True},
|
||||
},
|
||||
],
|
||||
provenance=[
|
||||
"maintenance:deploy-order-race (live incident: deploys 7355/7359)",
|
||||
"DEPLOY-MONOTONIC-001 (monotonic promotion + truthful outcomes, live-proven)",
|
||||
"release-safety repair 2026-09-13 (red-promotion incidents 0.66.28/29: required-checks aggregator lied)",
|
||||
"scripts/deploy_promotion.py (ancestry + fenced critical section)",
|
||||
"tests/test_deploy_stage_race.py (22-test matrix incl. two-process live race)",
|
||||
],
|
||||
)
|
||||
|
||||
UI_TUI_STRUCTURAL = ConstructionPattern(
|
||||
"ui-tui-structural-change",
|
||||
"UI/TUI structural changes: component identity, Layout Contract "
|
||||
"inheritance, USER-LOCKED preservation, deterministic verification.",
|
||||
maturity="UNIT-PROVEN",
|
||||
signals={
|
||||
"surface_kinds": ["ui_component", "layout_region"],
|
||||
"path_contains": ["tui", "ui", "widget"],
|
||||
"entities_match": ["ui:", "region:", "layout:"],
|
||||
},
|
||||
obligations=[
|
||||
{
|
||||
"id": "ui-layout-contract-inherited",
|
||||
"obligation": "affected UI components inherit their Layout Contract "
|
||||
"obligations; layout_contract_check must PASS",
|
||||
"when": {"entities_match": ["ui:", "region:", "layout:"]},
|
||||
},
|
||||
{
|
||||
"id": "ui-user-locked-preserved",
|
||||
"obligation": "USER-LOCKED placement is user authority: preserved "
|
||||
"exactly; changes require explicit user authorization",
|
||||
"when": {"entities_match": ["ui:", "region:"]},
|
||||
},
|
||||
{
|
||||
"id": "ui-unknown-never-pass",
|
||||
"obligation": "layout verification UNKNOWN (no adapter evidence) must never be reported as PASS",
|
||||
"when": {"entities_match": ["ui:", "region:", "layout:"]},
|
||||
},
|
||||
{
|
||||
"id": "ui-component-identity-stable",
|
||||
"obligation": "ui:<component> identity survives implementation-path "
|
||||
"changes; authority never keyed by filename alone",
|
||||
"when": {"entities_match": ["ui:", "region:", "layout:"]},
|
||||
},
|
||||
],
|
||||
provenance=[
|
||||
"issue #105 + plans/2026-09-03-tui-footer.md (version display)",
|
||||
"Tranche 3 real TUI proof (gate PASS -> violation FAIL -> restore)",
|
||||
"tests/test_layout_contract.py + tests/test_layout_contract_real_proof.py",
|
||||
"maintenance:version-display-user-lock (USER-LOCK upgrade pending "
|
||||
"user confirmation — provenance rule honored, not fabricated)",
|
||||
],
|
||||
)
|
||||
|
||||
STORE_SCHEMA_EVOLUTION = ConstructionPattern(
|
||||
"store-schema-evolution",
|
||||
"Durable store/schema changes: backward readability, authority "
|
||||
"preservation, deterministic roundtrip, migration only when necessary.",
|
||||
maturity="CANDIDATE",
|
||||
signals={
|
||||
"path_contains": ["store", "schema", "structural_store", "scaffold", "spine"],
|
||||
"entities_match": ["module:structural_store", "module:scaffold", "module:spine"],
|
||||
"objective_any": ["store", "schema", "record", "persist", "field", "migration"],
|
||||
},
|
||||
obligations=[
|
||||
{
|
||||
"id": "store-backward-readability",
|
||||
"obligation": "old records without new fields read as empty/unknown, never crash; legacy keys preserved",
|
||||
"when": {"path_contains": ["store", "structural_store", "scaffold", "spine", "schema"]},
|
||||
},
|
||||
{
|
||||
"id": "store-roundtrip",
|
||||
"obligation": "deterministic save/load roundtrip incl. unknown entity/relation kinds and open-world fields",
|
||||
"when": {"path_contains": ["store", "structural_store", "scaffold", "schema"]},
|
||||
},
|
||||
{
|
||||
"id": "store-authority-preservation",
|
||||
"obligation": "structural facts only change through their owning "
|
||||
"projector/authority; canonical rebuild after "
|
||||
"registration (no hand-edited structural facts)",
|
||||
"when": {"path_contains": ["spine", "scaffold", "store/"]},
|
||||
},
|
||||
{
|
||||
"id": "store-migration-only-if-needed",
|
||||
"obligation": "no mandatory migration unless semantics truly change; "
|
||||
"ext/open-world fields preferred over schema churn",
|
||||
"when": {"path_contains": ["schema", "store", "structural_store"]},
|
||||
},
|
||||
],
|
||||
provenance=[
|
||||
"Tranche 2 structural-store extraction (behavior-neutrality proof)",
|
||||
"Tranche 4 contract-quality repair (legacy keys preserved, old contracts readable — test_h)",
|
||||
"Tranche 5 expected-runtime-effects extension (old contracts close without new fields)",
|
||||
"Tranche 2 CI lesson: hand-edited spine store diverged from canonical rebuild (deploy-identity edges)",
|
||||
],
|
||||
)
|
||||
|
||||
BUILTIN_PATTERNS = [CAPABILITY_ADAPTATION, RELEASE_DEPLOYMENT, UI_TUI_STRUCTURAL, STORE_SCHEMA_EVOLUTION]
|
||||
|
||||
_REGISTRY: dict[str, ConstructionPattern] = {p.pattern_id: p for p in BUILTIN_PATTERNS}
|
||||
|
||||
|
||||
def get_pattern(pattern_id: str, revision: int | None = None) -> ConstructionPattern | None:
|
||||
"""Fetch a builtin pattern; a specific revision raises if the registry
|
||||
no longer carries it (contracts reference the revision they consumed —
|
||||
§17)."""
|
||||
p = _REGISTRY.get(pattern_id)
|
||||
if p is None:
|
||||
return None
|
||||
if revision is not None and p.revision != revision:
|
||||
raise ValueError(
|
||||
f"pattern {pattern_id} revision {revision} not in registry "
|
||||
f"(current: {p.revision}) — the consuming contract records which "
|
||||
f"revision it used"
|
||||
)
|
||||
return p
|
||||
@@ -0,0 +1,135 @@
|
||||
"""construction_projection.py — T16 §16I: stable versioned PUBLIC
|
||||
projection of construction state for future Substrate integration.
|
||||
|
||||
This is a PROJECTION, never a second authority. All fields are derived
|
||||
(read-only) from the canonical scaffold store (Scaffold.load) plus the
|
||||
live wait bridge state (event_wait.operation_wait). A consumer (the
|
||||
future Substrate, the mock client in tests) must know NOTHING about
|
||||
current.json layout or internal entity arrays — only this schema.
|
||||
|
||||
Schema versioning: SCHEMA_VERSION is bumped on any field change; consumers
|
||||
negotiate by reading schema_version first. Unknown future fields are
|
||||
ignored by readers (forward-tolerant); removed fields bump the major.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# The mandate's required projection fields (§16I). None may carry live
|
||||
# provider/account secrets or internal storage layout.
|
||||
FIELDS = (
|
||||
"schema_version", "project_id", "originating_goal_id",
|
||||
"construction_id", "contract_id",
|
||||
"mode", "depth", "phase", "stage",
|
||||
"operation_id", "waiting_state", "correlation_id",
|
||||
"builder_state", "blockers", "approval_required",
|
||||
"readiness_canary", "liveness_canary", "exit_canary",
|
||||
"verification_state", "checkpoint", "evidence_refs",
|
||||
"failure_class", "completion_state", "candidate_next_action",
|
||||
)
|
||||
|
||||
|
||||
def _contract_entity(doc: dict, contract_id: str) -> dict | None:
|
||||
for e in doc.get("entities", []):
|
||||
if e.get("id") == f"contract:{contract_id}":
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def project(
|
||||
scaffold, contract_id: str, *,
|
||||
originating_goal_id: str = "",
|
||||
operation_id: str = "",
|
||||
correlation_id: str = "",
|
||||
stage: str = "",
|
||||
builder_state: str = "",
|
||||
verification_state: str = "",
|
||||
checkpoint: str = "",
|
||||
failure_class: str = "",
|
||||
completion_state: str = "",
|
||||
) -> dict:
|
||||
"""Build the stable public projection for one contract. `scaffold` is
|
||||
a Rune Scaffold instance (the canonical store); all live fields are
|
||||
derived from it. Optional live fields (waiting_state etc.) are filled
|
||||
from the event bridge when operation_id is supplied."""
|
||||
doc = scaffold.load()
|
||||
c = _contract_entity(doc, contract_id) or {}
|
||||
ids = c.get("identities", {})
|
||||
ext = c.get("ext", {})
|
||||
trace = ext.get("decision_trace", {})
|
||||
|
||||
waiting_state = ""
|
||||
live_correlation = correlation_id
|
||||
if operation_id:
|
||||
try:
|
||||
from rune import event_wait as ew
|
||||
rec = ew.operation_wait(operation_id)
|
||||
if rec is not None:
|
||||
waiting_state = rec.get("status", "")
|
||||
live_correlation = correlation_id or rec.get("wait_id", "")
|
||||
except OSError: # bridge data root unreadable → degrade, never lie
|
||||
waiting_state = "BRIDGE_UNREACHABLE"
|
||||
except RuntimeError: # waitstore not resolvable in this context
|
||||
waiting_state = "BRIDGE_UNREACHABLE"
|
||||
|
||||
checkpoints = c.get("checkpoints", [])
|
||||
evidence_refs = []
|
||||
for cp in checkpoints:
|
||||
ev = cp.get("evidence", "")
|
||||
if ev:
|
||||
evidence_refs.append(ev[:120])
|
||||
|
||||
approval = trace.get("approval", {})
|
||||
approval_required = bool(
|
||||
approval.get("required") or approval.get("state") == "REQUIRED"
|
||||
or c.get("phase") == "awaiting_approval")
|
||||
|
||||
blockers: list[str] = []
|
||||
blk = ids.get("blocker")
|
||||
if isinstance(blk, dict):
|
||||
blockers.append(f"{blk.get('class', 'BLOCKED')}: "
|
||||
f"{blk.get('evidence', '')[:120]}")
|
||||
elif isinstance(blk, str) and blk:
|
||||
blockers.append(blk)
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"project_id": getattr(scaffold, "project_id", ""),
|
||||
"originating_goal_id": originating_goal_id,
|
||||
"construction_id": contract_id,
|
||||
"contract_id": contract_id,
|
||||
"mode": ids.get("construction_mode", "") or "",
|
||||
"depth": ids.get("depth", "") or "",
|
||||
"phase": c.get("phase", ""),
|
||||
"stage": stage,
|
||||
"operation_id": operation_id,
|
||||
"waiting_state": waiting_state,
|
||||
"correlation_id": live_correlation,
|
||||
"builder_state": builder_state,
|
||||
"blockers": blockers,
|
||||
"approval_required": approval_required,
|
||||
"readiness_canary": ext.get("readiness_canary", ""),
|
||||
"liveness_canary": ext.get("liveness_canary", ""),
|
||||
"exit_canary": ext.get("exit_canary", ""),
|
||||
"verification_state": verification_state,
|
||||
"checkpoint": checkpoint,
|
||||
"evidence_refs": evidence_refs,
|
||||
"failure_class": failure_class,
|
||||
"completion_state": completion_state,
|
||||
"candidate_next_action": ids.get("next_action", ""),
|
||||
}
|
||||
|
||||
|
||||
def validate(projection: dict) -> list[str]:
|
||||
"""Check a projection against the schema. Returns a list of errors
|
||||
(empty = valid). Consumers use this before trusting a projection."""
|
||||
errors = []
|
||||
if projection.get("schema_version") != SCHEMA_VERSION:
|
||||
errors.append(f"schema_version: expected {SCHEMA_VERSION}, got "
|
||||
f"{projection.get('schema_version')!r}")
|
||||
for f in FIELDS:
|
||||
if f not in projection:
|
||||
errors.append(f"missing field: {f}")
|
||||
return errors
|
||||
@@ -1,8 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""dependency_tracker.py — Auto-propagation system for umbrella dependencies.
|
||||
"""dependency_tracker.py — Auto-propagation for the OpenCode HARNESS scripts.
|
||||
|
||||
SCOPE CLASSIFICATION (Spine V1 pre-Memory closure, 2026-09-12): the
|
||||
DEPENDENCY_GRAPH below describes ~/.config/opencode/scripts/*.py — the
|
||||
developer HARNESS (verified: 20/23 names collide with src/rune modules but
|
||||
all exist in the scripts dir; propagate() resolves against SCRIPTS_DIR).
|
||||
This is NOT Rune architecture and NOT a competing structural authority:
|
||||
Rune's structural truth lives in the Spine store (store/spine/current.json,
|
||||
consumed via rune.spine / scripts/scaffold_projection.py). The name
|
||||
collisions with src/rune modules are coincidental — do not read this map
|
||||
as Rune structure.
|
||||
|
||||
When a component changes, automatically updates dependent components.
|
||||
Maps the dependency graph between umbrella systems and fires
|
||||
Maps the dependency graph between harness systems and fires
|
||||
propagation rules when changes are detected.
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""drift_resume.py — T16 §16P: drift-aware resume for months-later
|
||||
project re-entry.
|
||||
|
||||
Months-later resume must NOT blindly trust the last checkpoint. The
|
||||
16O benchmark proved both cold and scaffold-assisted paths converge on
|
||||
the same principle: checkpoint claims NEVER substitute for reality.
|
||||
|
||||
Flow:
|
||||
|
||||
load last verified scaffold state (expectations)
|
||||
↓
|
||||
inspect current repo identity/state (observations)
|
||||
↓
|
||||
compare expected vs actual → DriftRecord list
|
||||
↓
|
||||
unchanged: resume
|
||||
drift: classify EXPECTED / BENIGN / MATERIAL / CONFLICTING /
|
||||
UNKNOWN — MATERIAL/UNKNOWN can block dependent work
|
||||
|
||||
Reality wins unless the claim carries explicit user-locked authority
|
||||
(the one scaffold exception, already enforced by layout_contract).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
EXPECTED = "EXPECTED" # checkpoint older than reality; benign aging
|
||||
BENIGN = "BENIGN" # difference with no bearing on the claims
|
||||
MATERIAL = "MATERIAL" # reality contradicts a scaffold claim
|
||||
CONFLICTING = "CONFLICTING" # two authorities disagree; needs user
|
||||
UNKNOWN = "UNKNOWN" # cannot decide — treated as blocking
|
||||
|
||||
BLOCKING_CLASSES = (MATERIAL, CONFLICTING, UNKNOWN)
|
||||
|
||||
|
||||
def classify_drift(claim: dict, observation: dict) -> dict:
|
||||
"""Classify one claim-vs-observation pair. Deterministic:
|
||||
verification evidence beats prose; explicit beats inferred.
|
||||
|
||||
claim: {"claim": str, "kind": str, "verified_at": str,
|
||||
"user_locked": bool}
|
||||
observation: {"actual": str|None, "checked_by": str,
|
||||
"matches": bool|None}
|
||||
"""
|
||||
if observation.get("matches") is True:
|
||||
return {"claim": claim.get("claim", ""),
|
||||
"class": EXPECTED if claim.get("stale_ok") else "CONFIRMED",
|
||||
"evidence": observation.get("checked_by", "")}
|
||||
if observation.get("matches") is None:
|
||||
return {"claim": claim.get("claim", ""), "class": UNKNOWN,
|
||||
"evidence": f"unverifiable via {observation.get('checked_by', '?')}"}
|
||||
if claim.get("user_locked"):
|
||||
return {"claim": claim.get("claim", ""), "class": CONFLICTING,
|
||||
"evidence": ("reality differs from a USER-LOCKED claim "
|
||||
"— user arbitration required")}
|
||||
return {"claim": claim.get("claim", ""), "class": MATERIAL,
|
||||
"evidence": (f"reality differs: {observation.get('actual', '')}"
|
||||
f" (checked by {observation.get('checked_by', '?')})")}
|
||||
|
||||
|
||||
def reconcile(claims: list[dict], observer: Callable[[dict], dict]) -> dict:
|
||||
"""Reconcile a set of checkpoint claims against reality.
|
||||
|
||||
observer(claim) → observation dict (runs the actual check: test
|
||||
command, file read, git query — never trusts the claim text).
|
||||
|
||||
Returns {"resume": bool, "drifts": [...], "blocking": [...]}. The
|
||||
resume decision: drift-aware, not blind — ANY blocking-class drift
|
||||
stops dependent work until reconciled."""
|
||||
drifts = [classify_drift(c, observer(c)) for c in claims]
|
||||
blocking = [d for d in drifts if d["class"] in BLOCKING_CLASSES]
|
||||
return {"resume": not blocking, "drifts": drifts,
|
||||
"blocking": blocking}
|
||||
@@ -0,0 +1,457 @@
|
||||
"""event_wait.py — T15 §15D: event-driven wait as the CANONICAL DEFAULT
|
||||
for long-running construction operations (builders, CI, deploys).
|
||||
|
||||
GLOBAL WAITING INVARIANT (tranche brief):
|
||||
|
||||
EVENT DRIVES CONTINUATION.
|
||||
TIMEOUT DETECTS FAILURE OF CONTINUATION.
|
||||
|
||||
The W/R bridge (daemon.py + waitstore.py at RUNE_WR_ROOT, default
|
||||
~/.local/share/mimo-wait-resume) already provides: durable waits, event
|
||||
matching, per-session single-flight leases, process-tree ownership, and a
|
||||
resume driver. Until T15 its resume target was exclusively a MiMo
|
||||
session (global mimo-shaped resume_cmd), so Python controllers (stage
|
||||
drivers, builder sessions) could not be resumed by the bridge — which is
|
||||
exactly why fixed sleep/poll loops crept back into T12-T14.
|
||||
|
||||
This adapter CLOSES that gap with the smallest possible surface:
|
||||
|
||||
1. register_process_wait() — a controller/operation registers a
|
||||
PROCESS_EVENT wait (correlation: operation_id) whose per-wait
|
||||
resume_cmd writes a resume token to a local FIFO. Registration is a
|
||||
durable waitstore record: survives restarts, visible to reconciliation.
|
||||
|
||||
2. run_builder_under_events() — launches the real builder command under
|
||||
a notifier wrapper (separate session/process-group). The wrapper
|
||||
observes the builder's actual exit and POSTs the completion event to
|
||||
the daemon (/process-event). The wrapper is the event source; the
|
||||
driver does NOT wait on the builder directly.
|
||||
|
||||
3. await_resume_event() — the driver YIELDS: blocks in select() on the
|
||||
FIFO (no polling loop, no sleep floor) with a watchdog deadline. The
|
||||
daemon matches the event, advances the wait, and the per-wait
|
||||
resume_cmd fires immediately — the driver resumes on the EVENT, not
|
||||
after a configured delay.
|
||||
|
||||
4. Watchdog semantics: on timeout the driver performs a ONE-SHOT
|
||||
liveness inspection of the notifier process group (ALIVE → re-arm;
|
||||
GONE → PROCESS_LOST recovery). The timeout is a failure detector,
|
||||
never the scheduler.
|
||||
|
||||
Idempotency (15E) is inherited from the bridge: match() applies only to
|
||||
WAITING_EXTERNAL waits, so duplicate/late events cannot double-resume;
|
||||
correlation by operation_id (run_ref) means a stale event from a prior
|
||||
operation matches nothing; the reconciler never verifies PROCESS_EVENT
|
||||
waits against Gitea (the event is the authority).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_WR_ROOT = Path.home() / ".local" / "share" / "mimo-wait-resume"
|
||||
FALLBACK_WR_ROOT = Path("/home/nick/.local/share/mimo-wait-resume")
|
||||
DEFAULT_DAEMON_URL = "http://127.0.0.1:8791"
|
||||
|
||||
# The notifier wrapper: runs the real builder command as its child,
|
||||
# observes the ACTUAL exit, and POSTs the completion event to the daemon.
|
||||
# It is the event SOURCE — deliberately independent of the waiting driver.
|
||||
_NOTIFIER_SRC = r"""
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
op = sys.argv[1]; cmd = sys.argv[2:]
|
||||
start = time.time()
|
||||
proc = subprocess.run(cmd)
|
||||
completed_at = time.time()
|
||||
payload = {
|
||||
"operation_id": op,
|
||||
"exit_code": proc.returncode,
|
||||
"completed_at": completed_at,
|
||||
"started_at": start,
|
||||
}
|
||||
for attempt in range(3):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
os.environ["T15_DAEMON_URL"] + "/process-event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
r.read()
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
sys.stderr.write(f"event POST failed: {e}\n")
|
||||
# best-effort: the watchdog is the safety net for lost events
|
||||
time.sleep(1)
|
||||
"""
|
||||
|
||||
|
||||
def wr_root() -> Path:
|
||||
env = os.environ.get("RUNE_WR_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
if DEFAULT_WR_ROOT.exists():
|
||||
return DEFAULT_WR_ROOT
|
||||
return FALLBACK_WR_ROOT
|
||||
|
||||
|
||||
# waitstore instances cached per data root (isolated tests use per-test
|
||||
# tmp roots; the daemon uses its install dir). Loading via importlib.util
|
||||
# keeps the dynamic import pyright-clean — no unresolvable-import warning.
|
||||
_waitstore_cache: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _load_waitstore() -> Any:
|
||||
"""Load the bridge's OWN waitstore module (never a copy), bound to
|
||||
the adapter's data root. waitstore.py lives in the DAEMON CODE dir;
|
||||
in production WR_ROOT is that dir (code + data together); in isolated
|
||||
test environments WR_ROOT is a data-only dir, so the code dir is
|
||||
resolved separately: RUNE_WR_CODE env → WR_ROOT → known installs."""
|
||||
root = wr_root()
|
||||
# waitstore binds its DATA dir from WR_ROOT at import time. Pin
|
||||
# WR_ROOT to the adapter's authority so adapter-side and daemon-side
|
||||
# state agree — otherwise a mismatch silently splits the wait store
|
||||
# from the event matcher and continuation dies as a watchdog timeout.
|
||||
os.environ["WR_ROOT"] = str(root)
|
||||
cached = _waitstore_cache.get(str(root))
|
||||
if cached is not None:
|
||||
return cached
|
||||
code_dir = None
|
||||
for cand in (os.environ.get("RUNE_WR_CODE"), str(root),
|
||||
str(FALLBACK_WR_ROOT)):
|
||||
if cand and (Path(cand) / "waitstore.py").is_file():
|
||||
code_dir = Path(cand)
|
||||
break
|
||||
if code_dir is None:
|
||||
raise RuntimeError(
|
||||
"waitstore.py not found in known W/R roots "
|
||||
"(RUNE_WR_CODE, WR_ROOT, ~/.local/share/mimo-wait-resume)")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"waitstore", str(code_dir / "waitstore.py"))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load waitstore from {code_dir}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
_waitstore_cache[str(root)] = mod
|
||||
return mod
|
||||
|
||||
|
||||
def register_process_wait(operation_id: str, *, contract_id: str = "",
|
||||
project: str = "", objective: str = "",
|
||||
watched_for: str = "",
|
||||
provider: str = "builder",
|
||||
event_type: str = "builder_exit",
|
||||
sha: str = "",
|
||||
watchdog_secs: int | None = None) -> dict:
|
||||
"""Register a durable PROCESS_EVENT wait for one operation. The
|
||||
per-wait resume_cmd writes a resume token to the operation's FIFO —
|
||||
the bridge can resume a Python controller, not just MiMo sessions.
|
||||
|
||||
provider/event_type are the adapter's event identity on the shared
|
||||
bridge (16E: ONE wait primitive; builder / Gitea-CI / future adapters
|
||||
differ ONLY in these identity fields + their liveness probe — the
|
||||
registration, FIFO rendezvous, matching, idempotency, restart
|
||||
recovery, and watchdog semantics are the same code for all).
|
||||
Defaults preserve the T15 builder semantics."""
|
||||
ws = _load_waitstore()
|
||||
fifo = fifo_path(operation_id)
|
||||
fifo.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Do NOT unlink+recreate an existing fifo: a caller that opened its
|
||||
# read end before registering (open_fifo_reader, §16Y #10 race fix)
|
||||
# would be orphaned onto the old inode while the daemon's resume
|
||||
# writer opened the new one. The rendezvous is create-if-missing.
|
||||
if not fifo.exists():
|
||||
os.mkfifo(fifo)
|
||||
os.chmod(fifo, 0o666) # cross-user bridge (see open_fifo_reader)
|
||||
writer = ("import sys,json,time,os;"
|
||||
"fd=os.open(sys.argv[1], os.O_WRONLY | os.O_NONBLOCK);"
|
||||
"os.write(fd, json.dumps({'wait_id': sys.argv[2],"
|
||||
" 'resumed_at': time.time()}).encode());os.close(fd)")
|
||||
rec = ws.new_wait(
|
||||
session_id=f"controller-{operation_id}",
|
||||
provider=provider,
|
||||
event_type=event_type,
|
||||
sha=sha,
|
||||
repo=project or "construction",
|
||||
run_ref=operation_id,
|
||||
expected="success",
|
||||
wait_kind="PROCESS_EVENT",
|
||||
target_kind="headless", # owned continuation target: FIFO writer
|
||||
objective=objective or f"builder operation {operation_id}",
|
||||
waited_for=watched_for or "builder process exit event",
|
||||
verify_when_woken="reload canonical state; exit canary; "
|
||||
"independent verification",
|
||||
on_success="continue programme continuation canary",
|
||||
on_failure="classify failure; recovery or block",
|
||||
resume_cmd=["python3", "-c", writer, str(fifo), "{wait_id}"],
|
||||
)
|
||||
rec["ext"] = {"contract_id": contract_id, "operation_id": operation_id,
|
||||
"fifo": str(fifo),
|
||||
"watchdog_secs": watchdog_secs
|
||||
or int(os.environ.get("WR_WATCH_SECS", "600"))}
|
||||
ws.save(rec)
|
||||
# cross-user bridge (T16 §16C live finding): the daemon reconciler may
|
||||
# run under a different account than the registering controller — the
|
||||
# durable record must be readable/writable on both sides
|
||||
try:
|
||||
os.chmod(ws.WAITS / f"{rec['wait_id']}.json", 0o666)
|
||||
except OSError:
|
||||
pass # single-user deployment: nothing to relax
|
||||
return rec
|
||||
|
||||
|
||||
def fifo_path(operation_id: str) -> Path:
|
||||
return wr_root() / "resumes" / f"{operation_id}.fifo"
|
||||
|
||||
|
||||
def open_fifo_reader(operation_id: str) -> int:
|
||||
"""Open the operation's FIFO read end (O_NONBLOCK) BEFORE the wait is
|
||||
registered. §16Y adversarial #10: an event that arrives after
|
||||
registration but before the controller enters select() would find no
|
||||
reader — the resume write fails (ENXIO) and the wait lands
|
||||
RESUME_UNVERIFIED while the controller sleeps until the watchdog.
|
||||
Holding the reader open across the registration window closes that
|
||||
race for every adapter; the fd is passed to await_resume_event and
|
||||
closed by the caller."""
|
||||
fifo = fifo_path(operation_id)
|
||||
fifo.parent.mkdir(parents=True, exist_ok=True)
|
||||
if fifo.exists():
|
||||
fifo.unlink()
|
||||
os.mkfifo(fifo)
|
||||
# CROSS-USER BRIDGE (T17 17C root-cause, re-landed): the T16 chmod was
|
||||
# lost in a rebase resolution — FIFOs landed 644 (666 & umask 022),
|
||||
# and the daemon running as a DIFFERENT user got EACCES on the resume
|
||||
# write (rc=1 → RESUME_UNVERIFIED) while same-user tests passed.
|
||||
# chmod AFTER mkfifo is the only reliable act — umask never applies.
|
||||
os.chmod(fifo, 0o666)
|
||||
return os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
|
||||
|
||||
|
||||
def run_builder_under_events(builder_cmd: list[str], operation_id: str, *,
|
||||
daemon_url: str = DEFAULT_DAEMON_URL):
|
||||
"""Launch the REAL builder under the event notifier (separate process
|
||||
group). Returns the Popen for the notifier (pgid == its pid). The
|
||||
driver must NOT wait on this directly — it yields via
|
||||
await_resume_event; the notifier's exit event comes back through the
|
||||
bridge."""
|
||||
env = dict(os.environ, T15_DAEMON_URL=daemon_url)
|
||||
log_file = wr_root() / "resumes" / f"{operation_id}.builder.log"
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(log_file, "w") as lf:
|
||||
return subprocess.Popen(
|
||||
[sys.executable, "-c", _NOTIFIER_SRC, operation_id, *builder_cmd],
|
||||
stdout=lf, stderr=subprocess.PIPE,
|
||||
text=True, start_new_session=True, env=env)
|
||||
|
||||
|
||||
def _pgroup_alive(pgid: int) -> bool:
|
||||
"""Liveness = at least one NON-ZOMBIE member of the process group.
|
||||
A killed-but-unreaped notifier is a zombie and does NOT count —
|
||||
treating zombies as alive would misclassify PROCESS_LOST as alive."""
|
||||
try:
|
||||
os.killpg(pgid, 0) # group exists at all?
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
for proc in Path("/proc").iterdir():
|
||||
if not proc.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
stat = (proc / "stat").read_text()
|
||||
# field 5 is state; comm (field 2) may contain spaces/parens
|
||||
state = stat.rsplit(")", 1)[1].split()[0]
|
||||
pgid_field = int(stat.rsplit(")", 1)[1].split()[2])
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
if pgid_field == pgid and state != "Z":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _cmdline_alive(needle: str) -> bool:
|
||||
"""Liveness fallback: any live process whose cmdline contains the
|
||||
needle. Real builders (node CLIs) may re-group their children, so
|
||||
pgid alone can false-negative — T15Q observed a 65-minute real build
|
||||
outliving the watchdog AND invisible to the pgid scan. The needle
|
||||
should be operation-specific (the prompt marker), not generic."""
|
||||
for proc in Path("/proc").iterdir():
|
||||
if not proc.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmdline = (proc / "cmdline").read_bytes().decode(
|
||||
errors="replace")
|
||||
stat = (proc / "stat").read_text()
|
||||
state = stat.rsplit(")", 1)[1].split()[0]
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
if needle in cmdline and state != "Z":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def liveness(operation_id: str, pgid: int | None = None) -> bool:
|
||||
"""Watchdog helper: is the watched operation actually alive? pgid
|
||||
membership first; cmdline evidence as the regroup-tolerant fallback."""
|
||||
if pgid and _pgroup_alive(pgid):
|
||||
return True
|
||||
return _cmdline_alive(f"op:{operation_id}")
|
||||
|
||||
|
||||
def await_resume_event(operation_id: str, *, watchdog_secs: int,
|
||||
pgid: int | None = None,
|
||||
max_rearms: int = 20,
|
||||
fifo_fd: int | None = None) -> dict:
|
||||
"""YIELD until the bridge resumes this controller. Blocks in select()
|
||||
on the FIFO — no sleep, no polling.
|
||||
|
||||
fifo_fd: a pre-opened read end from open_fifo_reader() (§16Y #10 race
|
||||
fix — the fd must already exist when the wait becomes registered).
|
||||
Caller-owned fds are never closed here; on EOF (writer gone, no
|
||||
token) the call returns {"kind": "eof"} instead of busy-looping on an
|
||||
always-readable fd — the caller falls back to the durable waitstore
|
||||
record via resume_or_recover/operation_wait.
|
||||
|
||||
Watchdog semantics (§15D): the deadline is a FAILURE DETECTOR, never
|
||||
the scheduler. On expiry, liveness is inspected ONCE (pgid membership,
|
||||
then operation-specific cmdline evidence — real builders regroup their
|
||||
children): ALIVE → re-arm (select again, up to max_rearms); GONE →
|
||||
PROCESS_LOST. The T15Q live run proved why: a real 65-minute model
|
||||
build outlived a single 900s deadline, and node regrouping hid the
|
||||
processes from the pgid scan — neither is a failure of continuation.
|
||||
"""
|
||||
owned_fd = fifo_fd is None
|
||||
if owned_fd:
|
||||
fifo = fifo_path(operation_id)
|
||||
# idempotent re-arm: a prior await consumed+unlinked the fifo; the
|
||||
# rendezvous point is (re)created so watchdog re-arm and recovery
|
||||
# paths can wait again on the same operation
|
||||
fifo.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not fifo.exists():
|
||||
os.mkfifo(fifo)
|
||||
fd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
|
||||
else:
|
||||
fd = fifo_fd
|
||||
try:
|
||||
for arm in range(max_rearms + 1):
|
||||
deadline = time.monotonic() + watchdog_secs
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
r, _, _ = select.select([fd], [], [], remaining)
|
||||
if not r:
|
||||
continue
|
||||
data = os.read(fd, 4096)
|
||||
if not data:
|
||||
if owned_fd:
|
||||
continue
|
||||
# caller-owned fd at EOF: writer is gone without
|
||||
# delivering a token — never a busy loop; the durable
|
||||
# record decides what actually happened
|
||||
return {"kind": "eof"}
|
||||
try:
|
||||
token = json.loads(data.decode())
|
||||
except ValueError:
|
||||
break # malformed token → treat as expiry
|
||||
ws = _load_waitstore()
|
||||
rec = ws.load(token.get("wait_id", ""))
|
||||
return {"kind": "event", "wait": rec,
|
||||
"resumed_at": token.get("resumed_at")}
|
||||
# deadline expired: liveness decides re-arm vs PROCESS_LOST
|
||||
alive = liveness(operation_id, pgid)
|
||||
if not alive:
|
||||
return {"kind": "watchdog", "alive": False,
|
||||
"rearms": arm}
|
||||
return {"kind": "watchdog", "alive": liveness(operation_id, pgid),
|
||||
"rearms": max_rearms}
|
||||
finally:
|
||||
if owned_fd:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def operation_wait(operation_id: str) -> dict | None:
|
||||
ws = _load_waitstore()
|
||||
for rec in ws.all_waits():
|
||||
if rec.get("ext", {}).get("operation_id") == operation_id:
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def resume_or_recover(operation_id: str) -> dict:
|
||||
"""15E case 10 — controller restart after the event was persisted but
|
||||
before the controller handled it. The durable waitstore record is the
|
||||
authority, not the transient FIFO token: if the wait already reached a
|
||||
terminal/resumable state, return it directly (idempotent recovery, no
|
||||
duplicate transition); if still WAITING_EXTERNAL, re-arm the normal
|
||||
event wait."""
|
||||
rec = operation_wait(operation_id)
|
||||
if rec is None:
|
||||
return {"kind": "unknown_operation"}
|
||||
if rec.get("status") in ("COMPLETED", "FAILED", "READY_TO_RESUME",
|
||||
"READY_TO_RETRY", "RESUMING"):
|
||||
# RESUMING counts as recovered: the resume delivery is in flight
|
||||
# (or already consumed the rendezvous). The durable record is the
|
||||
# truth; the controller restarts from it, never from the token.
|
||||
return {"kind": "event", "wait": rec, "recovered": True}
|
||||
if rec.get("status") == "RESUME_UNVERIFIED" and \
|
||||
rec.get("process_event_evidence"):
|
||||
# the completion EVENT was processed (verdict recorded durably);
|
||||
# only the resume-token delivery was unverified (e.g. controller
|
||||
# was down, so no reader consumed the rendezvous). Canonical
|
||||
# state wins — this IS the restart-after-event case.
|
||||
return {"kind": "event", "wait": rec, "recovered": True}
|
||||
return {"kind": "still_waiting", "wait": rec}
|
||||
|
||||
|
||||
def find_matching_event(provider: str, event_type: str, run_ref: str) -> dict | None:
|
||||
"""Search the bridge's durable event log for an event matching this
|
||||
identity. T16 §16C live finding: on a serialized single-runner
|
||||
machine the deploy job ALWAYS starts after ci.yml completes — the
|
||||
completion event lands before the waiter registers, and a pure
|
||||
forward-only FIFO rendezvous can never deliver it (both production
|
||||
cycles fell back to the 900s deadline). Events are durable precisely
|
||||
so they can be replayed: a waiter that finds a matching event at
|
||||
registration goes straight to the authority read instead of sleeping
|
||||
for a wake that already happened."""
|
||||
import json as _json
|
||||
ws = _load_waitstore()
|
||||
events_dir = ws.EVENTS
|
||||
if not events_dir.is_dir():
|
||||
return None
|
||||
best = None
|
||||
for f in sorted(events_dir.glob("*.json"),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
rec = _json.loads(f.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
ev = rec.get("event", {})
|
||||
if (ev.get("provider") == provider
|
||||
and ev.get("event_type") == event_type
|
||||
and str(ev.get("run_ref", "")) == run_ref):
|
||||
best = ev # newest match wins
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def post_process_event(operation_id: str, exit_code: int, *,
|
||||
daemon_url: str = DEFAULT_DAEMON_URL) -> dict:
|
||||
"""Direct event injection (test seam + manual reconciliation). The
|
||||
normal path is the notifier wrapper; this exists so tests and
|
||||
watchdog recovery can emit the same event shape."""
|
||||
import urllib.request
|
||||
payload = {"operation_id": operation_id, "exit_code": exit_code,
|
||||
"completed_at": time.time()}
|
||||
req = urllib.request.Request(
|
||||
daemon_url + "/process-event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return json.loads(r.read())
|
||||
@@ -192,7 +192,8 @@ def _default_runner(prompt: str, artifact_path: str) -> None:
|
||||
import subprocess
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
cmd = [
|
||||
mimo_bin,
|
||||
"run",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""failure_recovery.py — T15 §15T: failure classification + recovery
|
||||
policy.
|
||||
|
||||
Before retrying, classify the failure:
|
||||
|
||||
TRANSIENT bounded immediate retry appropriate
|
||||
INTERMITTENT bounded retry/backoff
|
||||
KNOWN_PROCESS_DEFECT apply evidenced repair, then retry
|
||||
PERMANENT / INVALID do NOT retry blindly
|
||||
APPROVAL_REQUIRED block
|
||||
UNKNOWN bounded evidence probe, then block/escalate
|
||||
|
||||
No infinite retry loops: every class carries a retry budget, and
|
||||
PERMANENT/APPROVAL_REQUIRED never auto-retry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TRANSIENT = "TRANSIENT"
|
||||
INTERMITTENT = "INTERMITTENT"
|
||||
KNOWN_PROCESS_DEFECT = "KNOWN_PROCESS_DEFECT"
|
||||
PERMANENT = "PERMANENT"
|
||||
APPROVAL_REQUIRED = "APPROVAL_REQUIRED"
|
||||
PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
CLASSES = (TRANSIENT, INTERMITTENT, KNOWN_PROCESS_DEFECT, PERMANENT,
|
||||
APPROVAL_REQUIRED, PROVIDER_UNAVAILABLE, UNKNOWN)
|
||||
|
||||
# recovery policy per class: (max_retries, action)
|
||||
POLICY = {
|
||||
TRANSIENT: (2, "retry_immediately"),
|
||||
INTERMITTENT: (3, "retry_with_backoff"),
|
||||
KNOWN_PROCESS_DEFECT: (1, "apply_evidenced_repair_then_retry"),
|
||||
PERMANENT: (0, "do_not_retry"),
|
||||
APPROVAL_REQUIRED: (0, "block"),
|
||||
# T16 §16F: provider failures NEVER auto-retry and NEVER fall back
|
||||
# to another provider — preserve the real error, block the branch.
|
||||
PROVIDER_UNAVAILABLE: (0, "block_branch_preserve_error"),
|
||||
UNKNOWN: (1, "bounded_evidence_probe_then_block_or_escalate"),
|
||||
}
|
||||
|
||||
# deterministic markers (substring, lowercase) — the classifier is
|
||||
# evidence-first: explicit signals win; absence defaults to UNKNOWN
|
||||
_TRANSIENT_MARKERS = (
|
||||
"connection reset", "timeout", "timed out", "temporarily unavailable",
|
||||
"rate limit", "429", "503", "econnreset", "socket hang up",
|
||||
"service unavailable",
|
||||
)
|
||||
_INTERMITTENT_MARKERS = (
|
||||
"flaky", "intermittent", "occasionally fails", "sometimes fails",
|
||||
"retry succeeded",
|
||||
)
|
||||
_PERMANENT_MARKERS = (
|
||||
"invalid input", "schema-invalid", "not found", "no such file",
|
||||
"permission denied", "unsupported", "incompatible",
|
||||
"module not found", "syntaxerror", "typeerror",
|
||||
)
|
||||
_APPROVAL_MARKERS = (
|
||||
"approval required", "needs authorization", "user confirmation",
|
||||
"destructive", "external service", "new repository",
|
||||
"user-locked", "user locked",
|
||||
)
|
||||
_KNOWN_DEFECT_MARKERS = (
|
||||
"stale document", "forgotten save", "wrong path", "guessed api",
|
||||
"missing import", "no-op edit", "substring match", "wrong signature",
|
||||
)
|
||||
|
||||
|
||||
def classify_failure(error_text: str, *, context: dict | None = None) -> dict:
|
||||
"""Classify one failure into a recovery class. Deterministic: marker
|
||||
evidence first; explicit context hints (e.g. known defect id) second;
|
||||
UNKNOWN when nothing matches (never manufactured certainty)."""
|
||||
text = (error_text or "").lower()
|
||||
context = context or {}
|
||||
known_defect = context.get("known_defect_id")
|
||||
if known_defect:
|
||||
return {"class": KNOWN_PROCESS_DEFECT,
|
||||
"evidence": f"known defect: {known_defect}",
|
||||
"policy": POLICY[KNOWN_PROCESS_DEFECT]}
|
||||
# T16 §16F: an explicit provider-context failure wins over every
|
||||
# generic marker — the real status/API code is preserved verbatim in
|
||||
# the evidence, the branch blocks, and no fallback is attempted.
|
||||
if context.get("provider_error"):
|
||||
return {"class": PROVIDER_UNAVAILABLE,
|
||||
"evidence": (f"provider error preserved: {error_text}"
|
||||
if error_text else "provider error"),
|
||||
"policy": POLICY[PROVIDER_UNAVAILABLE]}
|
||||
if any(m in text for m in _APPROVAL_MARKERS):
|
||||
return {"class": APPROVAL_REQUIRED,
|
||||
"evidence": "approval marker present",
|
||||
"policy": POLICY[APPROVAL_REQUIRED]}
|
||||
if any(m in text for m in _PERMANENT_MARKERS):
|
||||
return {"class": PERMANENT, "evidence": "permanent marker present",
|
||||
"policy": POLICY[PERMANENT]}
|
||||
if any(m in text for m in _KNOWN_DEFECT_MARKERS):
|
||||
return {"class": KNOWN_PROCESS_DEFECT,
|
||||
"evidence": "known process-defect marker present",
|
||||
"policy": POLICY[KNOWN_PROCESS_DEFECT]}
|
||||
if any(m in text for m in _INTERMITTENT_MARKERS):
|
||||
return {"class": INTERMITTENT,
|
||||
"evidence": "intermittent marker present",
|
||||
"policy": POLICY[INTERMITTENT]}
|
||||
if any(m in text for m in _TRANSIENT_MARKERS):
|
||||
return {"class": TRANSIENT, "evidence": "transient marker present",
|
||||
"policy": POLICY[TRANSIENT]}
|
||||
return {"class": UNKNOWN, "evidence": "no marker matched",
|
||||
"policy": POLICY[UNKNOWN]}
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Minimal intent continuity foundation (overnight handoff PART 6).
|
||||
|
||||
A deterministic state machine for tracking detached intents — the
|
||||
smallest primitive that prevents "new good idea = new active objective."
|
||||
Not an ontology; not Substrate. Test-first, reload-safe, no LLM needed
|
||||
for state transitions once classification is supplied."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
CLASSIFICATIONS = ("BLOCKER", "FIX", "EXTENSION", "SUPERSEDING", "ORTHOGONAL", "LATER")
|
||||
STATUSES = ("ACTIVE", "DETOUR", "PARKED", "RESOLVED", "SUPERSEDED", "DEFERRED")
|
||||
|
||||
|
||||
class IntentRecord:
|
||||
__slots__ = (
|
||||
"id",
|
||||
"objective",
|
||||
"parent_intent",
|
||||
"active_detour",
|
||||
"return_when",
|
||||
"return_to",
|
||||
"unresolved_obligations",
|
||||
"success_condition",
|
||||
"classification",
|
||||
"status",
|
||||
"created_at",
|
||||
"resolved_at",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
id,
|
||||
objective,
|
||||
classification,
|
||||
parent_intent="",
|
||||
active_detour=False,
|
||||
return_when="",
|
||||
return_to="",
|
||||
unresolved_obligations=None,
|
||||
success_condition="",
|
||||
status="ACTIVE",
|
||||
):
|
||||
self.id = id
|
||||
self.objective = objective
|
||||
if classification not in CLASSIFICATIONS:
|
||||
raise ValueError(f"invalid classification {classification!r}")
|
||||
self.classification = classification
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"invalid status {status!r}")
|
||||
self.status = status
|
||||
self.parent_intent = parent_intent
|
||||
self.active_detour = active_detour
|
||||
self.return_when = return_when
|
||||
self.return_to = return_to
|
||||
self.unresolved_obligations = unresolved_obligations or []
|
||||
self.success_condition = success_condition
|
||||
self.created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
self.resolved_at = ""
|
||||
|
||||
def to_dict(self):
|
||||
return {s: getattr(self, s) for s in self.__slots__}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
rec = cls.__new__(cls)
|
||||
for s in cls.__slots__:
|
||||
setattr(rec, s, d.get(s, "" if s != "unresolved_obligations" else []))
|
||||
return rec
|
||||
|
||||
|
||||
class IntentLedger:
|
||||
"""Deterministic intent-state store. JSON persistence, no LLM."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = Path(path)
|
||||
self.records = {}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if self.path.is_file():
|
||||
data = json.loads(self.path.read_text())
|
||||
for rec in data.get("intents", []):
|
||||
r = IntentRecord.from_dict(rec)
|
||||
self.records[r.id] = r
|
||||
|
||||
def save(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps({"intents": [r.to_dict() for r in self.records.values()]}, indent=1))
|
||||
|
||||
def create(
|
||||
self, *, id, objective, classification, success_condition="", parent_intent="", unresolved_obligations=None
|
||||
):
|
||||
rec = IntentRecord(
|
||||
id=id,
|
||||
objective=objective,
|
||||
classification=classification,
|
||||
success_condition=success_condition,
|
||||
parent_intent=parent_intent,
|
||||
unresolved_obligations=unresolved_obligations,
|
||||
)
|
||||
self.records[id] = rec
|
||||
self.save()
|
||||
return rec
|
||||
|
||||
def create_detour(self, *, id, objective, classification, parent_intent, return_when, return_to):
|
||||
parent = self.records.get(parent_intent)
|
||||
if not parent:
|
||||
raise ValueError(f"parent {parent_intent!r} not found")
|
||||
rec = IntentRecord(
|
||||
id=id,
|
||||
objective=objective,
|
||||
classification=classification,
|
||||
parent_intent=parent_intent,
|
||||
active_detour=True,
|
||||
return_when=return_when,
|
||||
return_to=return_to,
|
||||
status="DETOUR",
|
||||
)
|
||||
self.records[id] = rec
|
||||
if parent.status == "ACTIVE":
|
||||
parent.status = "PARKED"
|
||||
self.save()
|
||||
return rec
|
||||
|
||||
def resolve(self, intent_id):
|
||||
"""Invariant 5: resolving a child does NOT resolve its parent."""
|
||||
rec = self.records.get(intent_id)
|
||||
if not rec or rec.status not in ("ACTIVE", "DETOUR"):
|
||||
return None
|
||||
rec.status = "RESOLVED"
|
||||
rec.resolved_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
self.save()
|
||||
return rec
|
||||
|
||||
def resolve_detour(self, detour_id):
|
||||
d = self.records.get(detour_id)
|
||||
if not d or d.status not in ("DETOUR", "RESOLVED"):
|
||||
return None
|
||||
if d.status == "DETOUR":
|
||||
d.status = "RESOLVED"
|
||||
d.resolved_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
parent = self.records.get(d.parent_intent)
|
||||
if parent and parent.status == "PARKED":
|
||||
parent.status = "ACTIVE"
|
||||
self.save()
|
||||
return parent
|
||||
|
||||
def supersede(self, old_id, new_id, *, objective, classification, carry_obligations=True):
|
||||
old = self.records[old_id]
|
||||
if old.unresolved_obligations and not carry_obligations:
|
||||
raise ValueError(f"cannot supersede {old_id}: {len(old.unresolved_obligations)} unresolved obligations")
|
||||
rec = IntentRecord(
|
||||
id=new_id,
|
||||
objective=objective,
|
||||
classification=classification,
|
||||
parent_intent=old.parent_intent,
|
||||
unresolved_obligations=list(old.unresolved_obligations) if carry_obligations else [],
|
||||
status="ACTIVE",
|
||||
)
|
||||
old.status = "SUPERSEDED"
|
||||
self.records[new_id] = rec
|
||||
self.save()
|
||||
return rec
|
||||
@@ -0,0 +1,316 @@
|
||||
"""layout_contract.py — persistent UI/TUI layout authority.
|
||||
|
||||
SCAFFOLD-TRANCHE-003. Preserves explicit user interface-placement decisions
|
||||
as durable structural constraints so future builders must adapt around them.
|
||||
|
||||
THE PROBLEM (tranche brief §11): a user says "version information belongs
|
||||
HERE"; a model implements it; later unrelated work moves it because another
|
||||
model prefers elsewhere. Unacceptable. User-approved layout is STRUCTURE:
|
||||
code adapts to the layout, never the reverse.
|
||||
|
||||
Authority model:
|
||||
LayoutContract AUTHORITY — the intended invariant
|
||||
layout observation EVIDENCE — what an adapter saw at some time
|
||||
region vocabulary in which contracts are expressed
|
||||
|
||||
DRIFT SEMANTICS — deliberate exception to general scaffold drift (§22):
|
||||
General project drift: reality wins, scaffold reconciles.
|
||||
USER-LOCKED layout drift: USER INTENT wins; the observation that
|
||||
violates a locked contract is marked DRIFTING, the contract is NOT
|
||||
rewritten, a maintenance_item is created, and the structural gate
|
||||
fails until repair or explicit user-approved amendment.
|
||||
|
||||
Validation is STRUCTURAL (adapter seam), never screenshot-based (§17-18):
|
||||
an adapter observes {component: region/order/parent}; the checker compares
|
||||
observation to contract. No adapter → UNKNOWN; UNKNOWN is never PASS (§23).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune.structural_store import add_entity, entity, now, relationships, resolve_status
|
||||
|
||||
__all__ = [
|
||||
"define_region",
|
||||
"bind_component",
|
||||
"amend_layout_contract",
|
||||
"record_layout_observation",
|
||||
"check_layout",
|
||||
"layout_drift",
|
||||
"STATUS",
|
||||
]
|
||||
|
||||
STATUS = {"ACTIVE", "AMENDED", "RETIRED", "DRIFTING"}
|
||||
|
||||
|
||||
# ── regions (open-world vocabulary) ───────────────────────────────────────
|
||||
|
||||
|
||||
def define_region(
|
||||
doc: dict,
|
||||
region_id: str,
|
||||
*,
|
||||
parent: str = "",
|
||||
order: int | None = None,
|
||||
orientation: str = "",
|
||||
sizing: str = "",
|
||||
visibility: str = "",
|
||||
tech: dict | None = None,
|
||||
provenance: str = "scaffold",
|
||||
evidence_refs: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Define/reinforce a layout region. Regions are open-world and
|
||||
project-defined — header/sidebar/main/inspector are examples, not enum.
|
||||
`tech` carries framework metadata (web DOM / Qt widgets / TUI panes)."""
|
||||
identities = {
|
||||
"parent": parent,
|
||||
"orientation": orientation,
|
||||
"sizing": sizing,
|
||||
"visibility": visibility,
|
||||
"provenance": provenance,
|
||||
"tech": dict(tech or {}),
|
||||
}
|
||||
if order is not None:
|
||||
identities["order"] = order
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"region:{region_id}",
|
||||
kind="layout_region",
|
||||
identities=identities,
|
||||
evidence_refs=evidence_refs or [f"region-defined:{provenance}"],
|
||||
)
|
||||
|
||||
|
||||
# ── component identity + contracts ─────────────────────────────────────────
|
||||
|
||||
|
||||
def bind_component(
|
||||
doc: dict,
|
||||
component: str,
|
||||
region_id: str,
|
||||
*,
|
||||
lock: bool = False,
|
||||
provenance: str = "scaffold",
|
||||
order: int | None = None,
|
||||
parent_of: str = "",
|
||||
anchor: str = "",
|
||||
sizing: str = "",
|
||||
visibility: str = "",
|
||||
evidence_ref: str = "",
|
||||
implementation: str = "",
|
||||
**framework_attrs,
|
||||
) -> dict:
|
||||
"""Bind a conceptual UI component (ui:<name>) to an approved layout
|
||||
requirement. The component id is STABLE STRUCTURAL IDENTITY: it survives
|
||||
filename/implementation/framework changes — `implementation` is a
|
||||
refreshable reference, never the key.
|
||||
|
||||
lock=True + provenance=user → USER-LOCKED: changing requires explicit
|
||||
user-authorized amendment (amend_layout_contract); models may NOT
|
||||
silently override (§14-15).
|
||||
|
||||
Framework-specific detail (CSS-ish or widget-ish) rides in
|
||||
**framework_attrs — open-world, never first-class core fields (§19)."""
|
||||
if not component.startswith("ui:"):
|
||||
component = f"ui:{component}"
|
||||
if entity(doc, f"region:{region_id}") is None:
|
||||
raise ValueError(f"unknown region: {region_id} (define_region first)")
|
||||
if lock and provenance != "user":
|
||||
raise ValueError("lock=True requires user provenance — a scaffold/builder/model can never self-issue a lock")
|
||||
identities = {
|
||||
"component": component,
|
||||
"region": region_id,
|
||||
"lock": lock,
|
||||
"provenance": provenance,
|
||||
"anchor": anchor,
|
||||
"sizing": sizing,
|
||||
"visibility": visibility,
|
||||
"implementation": implementation,
|
||||
"framework_attrs": dict(framework_attrs),
|
||||
}
|
||||
if order is not None:
|
||||
identities["order"] = order
|
||||
if parent_of:
|
||||
identities["parent_of"] = parent_of
|
||||
e = add_entity(
|
||||
doc,
|
||||
id=f"layout:{component}",
|
||||
kind="layout_contract",
|
||||
identities=identities,
|
||||
maturity="DECLARED",
|
||||
evidence_refs=[evidence_ref or f"layout-bind:{provenance}"],
|
||||
)
|
||||
e.setdefault("ext", {})["amendments"] = []
|
||||
e["status"] = "ACTIVE"
|
||||
return e
|
||||
|
||||
|
||||
def amend_layout_contract(
|
||||
doc: dict,
|
||||
component: str,
|
||||
*,
|
||||
new_region: str = "",
|
||||
new_order: int | None = None,
|
||||
unlock: bool = False,
|
||||
authorized_by: str = "",
|
||||
reason: str = "",
|
||||
evidence_ref: str = "",
|
||||
) -> dict:
|
||||
"""Amend a layout contract. A USER-LOCKED contract requires
|
||||
authorized_by='user' — enforced, not documented (§15). Every amendment
|
||||
is recorded on the contract; the original binding remains in history."""
|
||||
cid = component if component.startswith("ui:") else f"ui:{component}"
|
||||
e = entity(doc, f"layout:{cid}")
|
||||
if e is None:
|
||||
raise ValueError(f"no layout contract for {cid}")
|
||||
if e["identities"].get("lock"):
|
||||
if authorized_by != "user":
|
||||
raise ValueError(
|
||||
f"{cid} is USER-LOCKED: amendment requires explicit user "
|
||||
f"authorization (got authorized_by={authorized_by!r})"
|
||||
)
|
||||
if not reason:
|
||||
raise ValueError("user-locked amendment requires a reason")
|
||||
changes: dict = {}
|
||||
if new_region:
|
||||
if entity(doc, f"region:{new_region}") is None:
|
||||
raise ValueError(f"unknown region: {new_region}")
|
||||
e["identities"]["region"] = new_region
|
||||
changes["region"] = new_region
|
||||
if new_order is not None:
|
||||
e["identities"]["order"] = new_order
|
||||
changes["order"] = new_order
|
||||
if unlock:
|
||||
if authorized_by != "user":
|
||||
raise ValueError("unlock requires user authorization")
|
||||
e["identities"]["lock"] = False
|
||||
changes["lock"] = False
|
||||
e["ext"]["amendments"].append(
|
||||
{
|
||||
"at": now(),
|
||||
"authorized_by": authorized_by,
|
||||
"reason": reason[:200],
|
||||
"evidence": evidence_ref,
|
||||
"changes": changes,
|
||||
}
|
||||
)
|
||||
e["observed_at"] = now()
|
||||
return e
|
||||
|
||||
|
||||
# ── observations (evidence, NOT authority) ────────────────────────────────
|
||||
|
||||
|
||||
def record_layout_observation(doc: dict, adapter: str, observed: dict) -> list[dict]:
|
||||
"""Record what a structural adapter saw: {ui_component: {"region": ...,
|
||||
"order": ..., "parent": ...}}. Observations are evidence appended to the
|
||||
store; they never rewrite contracts (§17-18, §22)."""
|
||||
if not adapter:
|
||||
raise ValueError("observation requires adapter identity")
|
||||
out = []
|
||||
for comp, obs in observed.items():
|
||||
c = comp if comp.startswith("ui:") else f"ui:{comp}"
|
||||
out.append(
|
||||
{"kind": "layout_observed", "component": c, "adapter": adapter, "observed": dict(obs), "observed_at": now()}
|
||||
)
|
||||
doc["observations"].extend(out)
|
||||
return out
|
||||
|
||||
|
||||
# ── deterministic validation (the gate) ────────────────────────────────────
|
||||
|
||||
|
||||
def check_layout(doc: dict, observed: dict) -> dict:
|
||||
"""layout_contract_check gate. Verdicts (§23):
|
||||
PASS — every checked contract complies
|
||||
FAIL — a contract is verifiably violated (locked violations are
|
||||
additionally classed USER_LOCKED_VIOLATION — user intent,
|
||||
not code preference, defines correctness)
|
||||
UNKNOWN — no observation available for a locked contract (no
|
||||
adapter evidence) — NEVER converted to PASS
|
||||
Flexible components may be reorganized by builders via amendment, but a
|
||||
live observation contradicting any contract still fails the gate."""
|
||||
results = []
|
||||
verdict = "PASS"
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "layout_contract" or e.get("status") == "RETIRED":
|
||||
continue
|
||||
cid = e["identities"]["component"]
|
||||
required_region = e["identities"]["region"]
|
||||
locked = bool(e["identities"].get("lock"))
|
||||
required_order = e["identities"].get("order")
|
||||
obs = observed.get(cid) or observed.get(cid.split("ui:")[-1])
|
||||
if obs is None:
|
||||
results.append(
|
||||
{"component": cid, "locked": locked, "verdict": "UNKNOWN", "reason": "no adapter observation available"}
|
||||
)
|
||||
if locked:
|
||||
verdict = "UNKNOWN" if verdict == "PASS" else verdict
|
||||
continue
|
||||
problems = []
|
||||
got_region = obs.get("region", "")
|
||||
if got_region != required_region:
|
||||
problems.append(f"region {got_region!r} != required {required_region!r}")
|
||||
if required_order is not None and obs.get("order") is not None and obs["order"] != required_order:
|
||||
problems.append(f"order {obs['order']} != required {required_order}")
|
||||
if problems:
|
||||
r = {
|
||||
"component": cid,
|
||||
"locked": locked,
|
||||
"verdict": "FAIL",
|
||||
"reason": "; ".join(problems),
|
||||
"observed": obs,
|
||||
"required_region": required_region,
|
||||
}
|
||||
if locked:
|
||||
r["drift_class"] = "USER_LOCKED_VIOLATION"
|
||||
verdict = "FAIL" # any contract violation fails the gate (§14)
|
||||
results.append(r)
|
||||
else:
|
||||
results.append({"component": cid, "locked": locked, "verdict": "PASS"})
|
||||
return {"verdict": verdict, "checks": results}
|
||||
|
||||
|
||||
def layout_drift(doc: dict, observed: dict) -> dict:
|
||||
"""Reconcile layout observations with contracts. USER-LOCKED drift:
|
||||
the CONTRACT is preserved (user intent wins — the deliberate exception
|
||||
to general reality-wins drift), the violating observation is marked,
|
||||
and a maintenance_item records the inconsistency. Flexible drift: just
|
||||
recorded — builder may reorganize within constraints."""
|
||||
record_layout_observation(doc, "drift-check", observed) # evidence, not authority
|
||||
result = check_layout(doc, observed)
|
||||
drifts = [c for c in result["checks"] if c["verdict"] == "FAIL"]
|
||||
staled = []
|
||||
for d in drifts:
|
||||
le = entity(doc, f"layout:{d['component']}")
|
||||
if le is None:
|
||||
continue
|
||||
if d.get("locked"):
|
||||
le["status"] = "DRIFTING" # contract preserved, violation marked
|
||||
staled.append(d["component"])
|
||||
add_entity(
|
||||
doc,
|
||||
id=f"maintenance:layout-drift-{d['component']}",
|
||||
kind="maintenance_item",
|
||||
identities={
|
||||
"issue": f"locked layout contract violated: {d['reason']}",
|
||||
"evidence": f"adapter observation {d.get('observed')}",
|
||||
"approval": "user",
|
||||
"priority": "high",
|
||||
"status": "open",
|
||||
},
|
||||
evidence_refs=["layout-drift"],
|
||||
)
|
||||
le.setdefault("ext", {})["last_violation"] = {"at": now(), "observed": d.get("observed"), "reason": d["reason"]}
|
||||
# any previously-DRIFTING locked contract now compliant returns to ACTIVE
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] == "layout_contract" and e.get("status") == "DRIFTING":
|
||||
c = check_layout(doc, observed)
|
||||
comp = e["identities"]["component"]
|
||||
mine = next((x for x in c["checks"] if x["component"] == comp), None)
|
||||
if mine and mine["verdict"] == "PASS":
|
||||
e["status"] = "ACTIVE"
|
||||
return {"gate": result["verdict"], "drifted_locked": staled, "checks": result["checks"]}
|
||||
|
||||
|
||||
# keep relationships import used (regions may later carry containment rels)
|
||||
_ = relationships
|
||||
@@ -238,6 +238,7 @@ def context_length(model: str, base_url: str = "http://localhost:11434") -> int:
|
||||
conservative default 8192 + T1 ``record_failure("context_discovery")``
|
||||
— the assumption is observable, never silent (P0-06).
|
||||
"""
|
||||
from rune.openai_compat_transport import is_cloud_pinned
|
||||
from rune.subsystem_health import record_failure
|
||||
|
||||
with _CONTEXT_LENGTH_LOCK:
|
||||
@@ -245,7 +246,15 @@ def context_length(model: str, base_url: str = "http://localhost:11434") -> int:
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
window = OllamaChatTransport(base_url=base_url).context_length(model)
|
||||
if is_cloud_pinned(model):
|
||||
# Cloud-pinned models must not hit Ollama's /api/show — observed
|
||||
# live: 400 'invalid model name' + 8192 misbudget for a model
|
||||
# with a 131072 window. The provider config carries the window.
|
||||
from rune.openai_compat_transport import cloud_transport_for
|
||||
|
||||
window = cloud_transport_for(model).context_length(model)
|
||||
else:
|
||||
window = OllamaChatTransport(base_url=base_url).context_length(model)
|
||||
except Exception as exc:
|
||||
record_failure("context_discovery", exc, tier=1, context=model)
|
||||
return CONTEXT_LENGTH_FALLBACK
|
||||
|
||||
@@ -32,7 +32,12 @@ class OpenAICompatTransport:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.provider = provider_name
|
||||
self.timeout = timeout
|
||||
# Cloud round trips with a full tool schema routinely exceed the local
|
||||
# tier timeout (30s): observed live, glm-5.3-flash read-timeout at
|
||||
# 30.6s mid-iteration. Clamp the WIRE timeout to >=120s — the
|
||||
# escalation ladder still governs retries/tiers; this is one round
|
||||
# trip's rope, not policy.
|
||||
self.timeout = max(timeout, 120)
|
||||
self._context_window = context_window
|
||||
# Usage accumulators — same contract as OllamaChatTransport (I2).
|
||||
self.usage_calls = 0
|
||||
@@ -61,39 +66,55 @@ class OpenAICompatTransport:
|
||||
|
||||
@staticmethod
|
||||
def _to_openai_messages(messages: list[dict]) -> list[dict]:
|
||||
"""Ollama-shape → OpenAI-shape. Tool results carry tool_call_id
|
||||
already (the runtime's _tool_message sets it from request.call_id);
|
||||
assistant tool_calls gain ids and JSON-string arguments."""
|
||||
"""Ollama-shape → OpenAI-shape. Pairing: the runtime's _tool_message
|
||||
sets tool_call_id from request.call_id (minted by
|
||||
normalize_ollama_tool_calls from the response's own id). The assistant
|
||||
message must use THE SAME ids for its tool_calls — a mismatch makes
|
||||
the provider treat the tool result as orphaned and the model re-issues
|
||||
the identical call forever (observed live: glm-5.3-flash looped the
|
||||
same edit_file twice, empty terminal text both turns)."""
|
||||
out: list[dict] = []
|
||||
for m in messages:
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
m = messages[i]
|
||||
role = m.get("role")
|
||||
if role == "tool":
|
||||
out.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": m.get("tool_call_id") or f"call_{len(out)}",
|
||||
"content": m.get("content", ""),
|
||||
})
|
||||
continue
|
||||
tool_calls = m.get("tool_calls") or []
|
||||
if role == "assistant" and tool_calls:
|
||||
# Look ahead: pair each tool_call with the tool messages that
|
||||
# immediately follow, reusing their tool_call_ids.
|
||||
paired_ids = [messages[j].get("tool_call_id")
|
||||
for j in range(i + 1, len(messages))
|
||||
if messages[j].get("role") == "tool"][:len(tool_calls)]
|
||||
tc = []
|
||||
for i, c in enumerate(tool_calls):
|
||||
for k, c in enumerate(tool_calls):
|
||||
fn = c.get("function", {})
|
||||
args = fn.get("arguments", {})
|
||||
args_str = json.dumps(args) if isinstance(args, dict) else str(args)
|
||||
tc.append({
|
||||
"id": f"call_{len(out)}_{i}",
|
||||
"type": "function",
|
||||
"function": {"name": fn.get("name"), "arguments": args_str},
|
||||
})
|
||||
call_id = paired_ids[k] if k < len(paired_ids) else f"call_{i}_{k}"
|
||||
tc.append({"id": call_id or f"call_{i}_{k}", "type": "function",
|
||||
"function": {"name": fn.get("name"), "arguments": args_str}})
|
||||
out.append({"role": "assistant", "content": m.get("content") or None, "tool_calls": tc})
|
||||
i += 1
|
||||
continue
|
||||
if role == "tool":
|
||||
out.append({"role": "tool",
|
||||
"tool_call_id": m.get("tool_call_id") or f"call_{i}",
|
||||
"content": m.get("content", "")})
|
||||
i += 1
|
||||
continue
|
||||
out.append({"role": role, "content": m.get("content", "")})
|
||||
i += 1
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _from_openai_response(resp: dict) -> dict:
|
||||
"""OpenAI response → Ollama-shape (LocalToolRuntime's contract)."""
|
||||
"""OpenAI response → flat Ollama ChatMessage (LocalToolRuntime's contract).
|
||||
|
||||
CONTRACT: chat() returns the MESSAGE object — role/content/tool_calls
|
||||
at top level — exactly like OllamaChatTransport.chat. Observed live
|
||||
(PL1 Task A, 2026-09-12): the envelope shape {'message': {...}} made
|
||||
the runtime see 'empty terminal visible text' while the model had
|
||||
returned a valid read_file tool call."""
|
||||
choices = resp.get("choices") or [{}]
|
||||
message = choices[0].get("message", {}) or {}
|
||||
tool_calls = []
|
||||
@@ -105,10 +126,13 @@ class OpenAICompatTransport:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": args}
|
||||
tool_calls.append({"function": {"name": fn.get("name"), "arguments": args or {}}})
|
||||
out: dict = {"message": {"role": "assistant", "content": message.get("content") or ""}}
|
||||
entry = {"function": {"name": fn.get("name"), "arguments": args or {}}}
|
||||
if c.get("id"):
|
||||
entry["id"] = c["id"]
|
||||
tool_calls.append(entry)
|
||||
out: dict = {"role": "assistant", "content": message.get("content") or ""}
|
||||
if tool_calls:
|
||||
out["message"]["tool_calls"] = tool_calls
|
||||
out["tool_calls"] = tool_calls
|
||||
return out
|
||||
|
||||
# -- ChatTransport protocol -------------------------------------------
|
||||
@@ -150,6 +174,16 @@ class OpenAICompatTransport:
|
||||
return self._context_window
|
||||
|
||||
|
||||
def is_cloud_pinned(model: str) -> bool:
|
||||
"""THE provider-prefix rule, single authority: True when the model name
|
||||
requires the [OI]-compat wire — a non-empty provider prefix that is
|
||||
neither ollama nor local. Consumers: transport selection
|
||||
(rune_operator._transport_for) and context discovery
|
||||
(local_tool_runtime.context_length). Never copy this rule — import it."""
|
||||
prefix = model.split("/", 1)[0].strip().lower() if "/" in model else ""
|
||||
return bool(prefix) and prefix not in ("ollama", "local")
|
||||
|
||||
|
||||
def cloud_transport_for(model: str, timeout: float = 120) -> OpenAICompatTransport:
|
||||
"""Resolve provider config for a '<Provider>/<model>' name from the same
|
||||
source of truth the mimo CLI uses (mimocode.jsonc + auth.json)."""
|
||||
@@ -184,6 +218,10 @@ def cloud_transport_for(model: str, timeout: float = 120) -> OpenAICompatTranspo
|
||||
key = (auth.get(provider_name) or {}).get("key", "")
|
||||
if not key:
|
||||
raise ValueError(f"no API key for provider {provider_name!r}")
|
||||
# Cloud routers queue and batch — a tool-loop turn can legitimately exceed
|
||||
# the local-model timeout (observed live: glm-5.3-flash read-timeout at
|
||||
# 120s twice). Floor cloud transports at 300s; local keeps its own.
|
||||
return OpenAICompatTransport(
|
||||
base_url, key, provider_name, timeout=timeout, context_window=context_window
|
||||
base_url, key, provider_name, timeout=max(timeout, 300),
|
||||
context_window=context_window,
|
||||
)
|
||||
@@ -0,0 +1,349 @@
|
||||
"""programme_continuation.py — T15 §15M: the PROGRAMME CONTINUATION
|
||||
CANARY.
|
||||
|
||||
The user observation this answers: subtask completion repeatedly ended
|
||||
whole programmes. "task done" is not "programme done".
|
||||
|
||||
After ANY stage/subtask reports complete, the runner asks one bounded
|
||||
question — is the overall construction/programme objective satisfied?
|
||||
If not, and eligible authorized stages remain, the NEXT stage starts
|
||||
immediately (no user re-prompt, no voluntary stop). Blocked branches
|
||||
fall back to pre-authorized independent work.
|
||||
|
||||
Boundary (tranche brief): this is a bounded CONSTRUCTION-programme rule
|
||||
over Scaffolding stage contracts — NOT the future general-purpose
|
||||
Substrate scheduler. It knows nothing outside one programme's stage
|
||||
list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
||||
# stage states
|
||||
PENDING = "PENDING"
|
||||
ELIGIBLE = "ELIGIBLE"
|
||||
WAITING_EVENT = "WAITING_EVENT"
|
||||
COMPLETE = "COMPLETE"
|
||||
BLOCKED = "BLOCKED"
|
||||
APPROVAL_REQUIRED = "APPROVAL_REQUIRED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
TERMINAL = {COMPLETE, BLOCKED, APPROVAL_REQUIRED, FAILED}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgrammeStage:
|
||||
stage_id: str
|
||||
objective: str
|
||||
state: str = PENDING
|
||||
depends_on: list = field(default_factory=list) # stage_ids
|
||||
priority: int = 0 # lower = sooner
|
||||
result: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"stage_id": self.stage_id, "objective": self.objective,
|
||||
"state": self.state, "depends_on": list(self.depends_on),
|
||||
"priority": self.priority}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Programme:
|
||||
"""One bounded construction programme: its objective, its authorized
|
||||
stages, and the continuation rule. Persisted to the contract store's
|
||||
project area (durable, reloadable, no transcript dependency)."""
|
||||
programme_id: str
|
||||
objective: str
|
||||
stages: list = field(default_factory=list) # [ProgrammeStage]
|
||||
fallbacks: list = field(default_factory=list) # pre-authorized ids
|
||||
|
||||
# ── the continuation canary (15M) ────────────────────────────────
|
||||
def continuation_canary(self) -> dict:
|
||||
"""'task done' ≠ 'programme done'. Returns the verdict AND the
|
||||
next action — the runner never has to guess whether to stop."""
|
||||
complete = [s for s in self.stages if s.state == COMPLETE]
|
||||
if len(complete) == len(self.stages):
|
||||
return {"verdict": "PROGRAMME_COMPLETE",
|
||||
"next_action": None,
|
||||
"reason": f"all {len(self.stages)} stages complete"}
|
||||
eligible = self.eligible_stages()
|
||||
if eligible:
|
||||
nxt = eligible[0]
|
||||
return {"verdict": "PROGRAMME_OPEN",
|
||||
"next_action": ("start", nxt.stage_id),
|
||||
"reason": (f"{len(complete)}/{len(self.stages)} "
|
||||
f"complete; {len(eligible)} eligible — "
|
||||
f"continue immediately")}
|
||||
waiting = [s for s in self.stages
|
||||
if s.state == WAITING_EVENT]
|
||||
if waiting:
|
||||
return {"verdict": "PROGRAMME_OPEN_WAITING",
|
||||
"next_action": ("await_event", waiting[0].stage_id),
|
||||
"reason": "event-driven wait active — no polling"}
|
||||
blocked = [s for s in self.stages if s.state == BLOCKED]
|
||||
if blocked and self.fallbacks:
|
||||
return {"verdict": "PROGRAMME_OPEN",
|
||||
"next_action": ("fallback", self.fallbacks[0]),
|
||||
"reason": (f"{len(blocked)} blocked; pre-authorized "
|
||||
f"fallback available")}
|
||||
if blocked:
|
||||
return {"verdict": "ALL_REMAINING_BLOCKED",
|
||||
"next_action": None,
|
||||
"reason": f"{len(blocked)} stage(s) blocked, no fallback"}
|
||||
approval = [s for s in self.stages
|
||||
if s.state == APPROVAL_REQUIRED]
|
||||
if approval:
|
||||
return {"verdict": "APPROVAL_REQUIRED",
|
||||
"next_action": None,
|
||||
"reason": (f"{len(approval)} stage(s) need user "
|
||||
f"authorization")}
|
||||
# T17 §17E: all stages terminal but some FAILED — the programme
|
||||
# may still close (programme-level acceptance decides), never
|
||||
# hang in UNKNOWN_INTERRUPTION.
|
||||
if all(s.state in TERMINAL for s in self.stages):
|
||||
failed = [s.stage_id for s in self.stages if s.state == FAILED]
|
||||
return {"verdict": "PROGRAMME_COMPLETE_WITH_FAILURES",
|
||||
"next_action": None,
|
||||
"reason": (f"all stages terminal; failed: {failed} — "
|
||||
f"programme-level acceptance decides")}
|
||||
return {"verdict": "UNKNOWN_INTERRUPTION", "next_action": None,
|
||||
"reason": "no stage runnable and none terminal"}
|
||||
|
||||
def eligible_stages(self) -> list:
|
||||
"""PENDING stages whose dependencies are COMPLETE, priority-first.
|
||||
ELIGIBLE counts as runnable too — an eligible stage nobody started
|
||||
is exactly the case the continuation canary exists to catch."""
|
||||
done = {s.stage_id for s in self.stages if s.state == COMPLETE}
|
||||
out = [s for s in self.stages
|
||||
if s.state in (PENDING, ELIGIBLE)
|
||||
and all(d in done for d in s.depends_on)]
|
||||
out.sort(key=lambda s: (s.priority, s.stage_id))
|
||||
return out
|
||||
|
||||
# ── durable persistence (canonical store, not transcript) ────────
|
||||
def save(self, base: Path) -> Path:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
p = base / f"{self.programme_id}.json"
|
||||
p.write_text(json.dumps({
|
||||
"programme_id": self.programme_id,
|
||||
"objective": self.objective,
|
||||
"stages": [s.to_dict() for s in self.stages],
|
||||
"fallbacks": list(self.fallbacks),
|
||||
"saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime()),
|
||||
}, indent=1))
|
||||
return p
|
||||
|
||||
@classmethod
|
||||
def load(cls, base: Path, programme_id: str) -> "Programme | None":
|
||||
p = base / f"{programme_id}.json"
|
||||
if not p.exists():
|
||||
return None
|
||||
d = json.loads(p.read_text())
|
||||
prog = cls(programme_id=d["programme_id"],
|
||||
objective=d["objective"], fallbacks=d.get("fallbacks", []))
|
||||
for s in d["stages"]:
|
||||
prog.stages.append(ProgrammeStage(**s))
|
||||
return prog
|
||||
|
||||
|
||||
def run_programme(prog: Programme, stage_runner, *,
|
||||
max_transitions: int = 100) -> list[dict]:
|
||||
"""Drive one programme to a legitimate stop. `stage_runner(stage)` —
|
||||
provided by the caller — must return a state for the stage. Between
|
||||
EVERY stage: persist → continuation canary → next. Returns the
|
||||
transition log (the 15M proof artifact)."""
|
||||
log: list[dict] = []
|
||||
for _ in range(max_transitions):
|
||||
prog_canary = prog.continuation_canary()
|
||||
log.append({"canary": prog_canary})
|
||||
verdict, action = prog_canary["verdict"], prog_canary["next_action"]
|
||||
if action is None:
|
||||
break # legitimate stop — reason recorded in the log
|
||||
kind, target = action
|
||||
if kind == "fallback":
|
||||
log.append({"fallback_started": target})
|
||||
break
|
||||
stage = next(s for s in prog.stages if s.stage_id == target)
|
||||
stage.state = stage_runner(stage) # runner decides the new state
|
||||
log.append({"stage": target, "new_state": stage.state})
|
||||
return log
|
||||
|
||||
|
||||
# ── T17 §17D: production wiring — Scaffolding-integrated driver ─────────
|
||||
#
|
||||
# The 15M canary proved the RULE; this adapter makes it CANONICAL. One
|
||||
# programme = a list of stages; each stage maps 1:1 to a Scaffolding
|
||||
# contract (stage_id == contract_id). Stages run through the Construction
|
||||
# Interface lifecycle (approved→building→verifying→closed); asynchronous
|
||||
# stage work runs under the event bridge (WAITING_EVENT is a state, not
|
||||
# a stop). Persisted to the scaffold store's programme area — reloadable
|
||||
# after restart with no transcript.
|
||||
|
||||
DEFAULT_PROGRAMME_AREA = "programmes"
|
||||
|
||||
|
||||
def _programme_base(scaffold_store: Path) -> Path:
|
||||
return Path(scaffold_store) / DEFAULT_PROGRAMME_AREA
|
||||
|
||||
|
||||
def scaffolding_stage_runner(interface, *, scaffold_store: Path,
|
||||
daemon_url: str = "",
|
||||
watchdog_secs: int = 900):
|
||||
"""Return a stage_runner for run_programme bound to the Construction
|
||||
Interface. The stage's work is read from stage.result["work"] — a
|
||||
callable (interface, stage) → dict with {"ok": bool, "evidence": str,
|
||||
"async_cmd": [argv] optional}. When async_cmd is present the work
|
||||
runs under the event bridge and the driver resumes on the EVENT;
|
||||
canonical verification still decides completion truth. Stages without
|
||||
work complete as governance-only transitions."""
|
||||
from rune import event_wait as ew # local import: optional dependency
|
||||
|
||||
def _phase(contract_id: str) -> str:
|
||||
# ALWAYS re-read: transition() saves through its own doc copy, so
|
||||
# a locally-held contract dict goes stale after any transition
|
||||
# (T17 17E live finding — the draft→approved→building chain
|
||||
# silently stopped after the first step on a stale reference).
|
||||
doc = interface.scaffold.load()
|
||||
c = interface.scaffold.contract(doc, contract_id)
|
||||
return (c or {}).get("phase", "")
|
||||
|
||||
def run(stage: ProgrammeStage) -> str:
|
||||
contract_id = stage.stage_id
|
||||
phase = _phase(contract_id)
|
||||
if phase == "":
|
||||
stage.result["error"] = f"no contract {contract_id}"
|
||||
return FAILED
|
||||
if phase == "closed":
|
||||
return COMPLETE # idempotent re-entry (duplicate signal case)
|
||||
if phase == "draft":
|
||||
interface.transition(contract_id, "approved",
|
||||
evidence_ref="programme-stage-start")
|
||||
if _phase(contract_id) == "approved":
|
||||
interface.transition(contract_id, "building",
|
||||
evidence_ref="programme-stage-start")
|
||||
work = cast("Any", stage.result.get("work"))
|
||||
if work is None:
|
||||
interface.transition(contract_id, "verifying",
|
||||
evidence_ref="governance-only-stage")
|
||||
interface.close(
|
||||
contract_id,
|
||||
observed_evidence=[{"source": "programme-driver",
|
||||
"detail": "governance-only stage: "
|
||||
"no work, closure by "
|
||||
"programme acceptance"}],
|
||||
scope_files=[])
|
||||
return COMPLETE
|
||||
# work is either a callable (interface, stage) → outcome dict, or
|
||||
# a literal outcome spec dict ({"ok", "evidence", "async_cmd"?})
|
||||
outcome: dict = (dict(cast("dict", work(interface, stage)))
|
||||
if callable(work) else dict(cast("dict", work)))
|
||||
async_cmd = outcome.get("async_cmd")
|
||||
if async_cmd:
|
||||
op = f"stage-{contract_id}-{int(time.time())}"
|
||||
ew.register_process_wait(
|
||||
op, contract_id=contract_id,
|
||||
project=getattr(interface.scaffold, "project_id", ""),
|
||||
objective=stage.objective,
|
||||
watched_for="stage work completion event",
|
||||
watchdog_secs=watchdog_secs)
|
||||
notifier = ew.run_builder_under_events(
|
||||
async_cmd, op, daemon_url=daemon_url or ew.DEFAULT_DAEMON_URL)
|
||||
stage.result["operation_id"] = op
|
||||
stage.result["notifier_pgid"] = notifier.pid
|
||||
result = ew.await_resume_event(op, watchdog_secs=watchdog_secs,
|
||||
pgid=notifier.pid)
|
||||
if result["kind"] != "event":
|
||||
stage.result["error"] = f"stage event wait: {result['kind']}"
|
||||
interface.transition(contract_id, "failed",
|
||||
evidence_ref="stage-event-lost")
|
||||
return FAILED
|
||||
rec: dict = result.get("wait") or {}
|
||||
outcome["evidence"] = (f"event resume; wait {rec.get('wait_id')} "
|
||||
f"verdict={rec.get('verdict')}")
|
||||
if not outcome.get("ok"):
|
||||
stage.result["error"] = outcome.get("evidence", "work failed")
|
||||
# truthful failure: record the evidence ON the contract and
|
||||
# drive it to the failed phase via the legal transition map
|
||||
# (building→failed is legal; no exception swallowing)
|
||||
doc = interface.scaffold.load()
|
||||
c = interface.scaffold.contract(doc, contract_id)
|
||||
if c is not None:
|
||||
c.setdefault("checkpoints", []).append({
|
||||
"at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime()),
|
||||
"phase": c.get("phase", ""),
|
||||
"evidence": "programme-stage-failed",
|
||||
"note": (outcome.get("evidence") or "work failed")[:200],
|
||||
})
|
||||
interface.scaffold.save(doc)
|
||||
phase = c.get("phase", "")
|
||||
if phase == "building":
|
||||
interface.transition(contract_id, "failed",
|
||||
evidence_ref="programme-stage-failed")
|
||||
return FAILED
|
||||
# completion truth: work evidence recorded ON the contract, then
|
||||
# closed (evidence lives on the durable object, not the log)
|
||||
if _phase(contract_id) == "building":
|
||||
interface.transition(contract_id, "verifying",
|
||||
evidence_ref="programme-stage-verify")
|
||||
doc = interface.scaffold.load()
|
||||
c = interface.scaffold.contract(doc, contract_id)
|
||||
if c is not None:
|
||||
c.setdefault("checkpoints", []).append({
|
||||
"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"phase": "verifying",
|
||||
"evidence": "programme-stage-work",
|
||||
"note": (outcome.get("evidence") or "work completed")[:200],
|
||||
})
|
||||
interface.scaffold.save(doc)
|
||||
interface.close(
|
||||
contract_id,
|
||||
observed_evidence=[{"source": "programme-driver",
|
||||
"detail": outcome.get("evidence", "ok")}],
|
||||
scope_files=[])
|
||||
return COMPLETE
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def run_construction_programme(prog: Programme, interface, *,
|
||||
scaffold_store: Path,
|
||||
daemon_url: str = "",
|
||||
watchdog_secs: int = 900,
|
||||
max_transitions: int = 100) -> list[dict]:
|
||||
"""Canonical multi-stage construction continuation (T17 §17D):
|
||||
run_programme with the Scaffolding-bound runner; persists the
|
||||
programme around every transition so a restart resumes from the
|
||||
durable record, never a transcript. Returns the transition log."""
|
||||
runner = scaffolding_stage_runner(
|
||||
interface, scaffold_store=scaffold_store,
|
||||
daemon_url=daemon_url, watchdog_secs=watchdog_secs)
|
||||
log: list[dict] = []
|
||||
base = _programme_base(scaffold_store)
|
||||
for _ in range(max_transitions):
|
||||
prog.save(base)
|
||||
canary = prog.continuation_canary()
|
||||
log.append({"canary": canary})
|
||||
verdict, action = canary["verdict"], canary["next_action"]
|
||||
if action is None:
|
||||
break
|
||||
kind, target = action
|
||||
if kind == "fallback":
|
||||
log.append({"fallback_started": target})
|
||||
break
|
||||
stage = next(s for s in prog.stages if s.stage_id == target)
|
||||
if stage.state == COMPLETE:
|
||||
continue # duplicate continuation signal: idempotent
|
||||
stage.state = ELIGIBLE
|
||||
new_state = runner(stage)
|
||||
stage.state = new_state
|
||||
log.append({"stage": target, "new_state": new_state})
|
||||
prog.save(base)
|
||||
prog.save(base)
|
||||
return log
|
||||
@@ -0,0 +1,48 @@
|
||||
"""provider_context.py — T16 §16F: provider ownership boundary.
|
||||
|
||||
ARCHITECTURE RULE (T16 mandate, permanent):
|
||||
|
||||
SCAFFOLDING DOES NOT OWN PROVIDER SELECTION.
|
||||
|
||||
Provider, model, provider fallback, provider source, and provider account
|
||||
belong to the surrounding Rune/TUI/provider layer. Construction code never
|
||||
names a provider. The ONLY supported way for a driver/probe (builder
|
||||
session, research stage, evidence probe — all PROVIDER_LAYER callers) to
|
||||
learn which model to use is the ambient provider context, i.e. the
|
||||
environment the surrounding layer sets.
|
||||
|
||||
works → continue
|
||||
missing/unresolvable → fail LOUDLY with PROVIDER_UNRESOLVED
|
||||
provider call fails → preserve the REAL error and status/API code,
|
||||
classify BLOCKED/PROVIDER_UNAVAILABLE, stop the dependent branch
|
||||
|
||||
NO silent default provider. NO automatic fallback. NO preference mutation.
|
||||
(Automatic provider fallback is DEFERRED to the future multi-source /
|
||||
local-model architecture.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class ProviderUnresolved(RuntimeError):
|
||||
"""Raised when no provider context is available and a silent default
|
||||
would otherwise have been selected. Deliberately loud: provider
|
||||
selection is a user-layer decision, never a code default."""
|
||||
|
||||
|
||||
def require_model(env_var: str = "MIMO_MODEL") -> str:
|
||||
"""Resolve the current model from the ambient provider context.
|
||||
|
||||
The surrounding provider layer (TUI / operator / session environment)
|
||||
owns this variable. A driver or probe MUST NOT carry its own default:
|
||||
a missing value is a configuration gap the user layer must fix, not
|
||||
something construction code may paper over by picking a provider."""
|
||||
model = (os.environ.get(env_var) or "").strip()
|
||||
if not model:
|
||||
raise ProviderUnresolved(
|
||||
f"PROVIDER_UNRESOLVED: {env_var} is not set — provider/model "
|
||||
f"selection belongs to the provider layer (set {env_var} in "
|
||||
f"the calling environment); no silent default is permitted "
|
||||
f"(T16 §16F)")
|
||||
return model
|
||||
@@ -0,0 +1,439 @@
|
||||
"""release_notes.py — evidence-derived Gitea release history.
|
||||
|
||||
SCAFFOLD-TRANCHE-009. Prime invariant (tranche brief):
|
||||
|
||||
Git records exact source history. Construction Contracts / maintenance
|
||||
items / tests / deployment records provide governed evidence. Gitea
|
||||
Releases provide the HUMAN-READABLE HISTORICAL ACCOUNT of what actually
|
||||
improved in each Rune release.
|
||||
|
||||
Release notes are evidence-derived history, not marketing prose:
|
||||
- facts come from governed records (closed contracts, resolved
|
||||
maintenance items, contract-quality records, observed evidence,
|
||||
lock/deploy artifacts) in that priority order;
|
||||
- commit messages alone never overrule governed evidence;
|
||||
- model prose may COMPRESS verified facts; it may not invent
|
||||
importance, completion, or maturity (§14 — this generator is fully
|
||||
deterministic; no model step exists here).
|
||||
|
||||
RELEASE SEMANTICS (§6, audited 2026-09-13):
|
||||
VERSION the semver in pyproject.toml / __init__ (e.g. 0.66.44)
|
||||
TAG v<VERSION>, created by auto-version on the BUMP commit
|
||||
release commit (tag SHA) the auto-bump commit — NOT necessarily the
|
||||
final state of the release
|
||||
converged deployed identity the last commit deployed under the same
|
||||
VERSION (same-version convergence commits are normal:
|
||||
closure/accounting/dependency-lock commits land and deploy
|
||||
after the bump without a version change)
|
||||
|
||||
The notes record BOTH: release_sha (tag target) and deployed_sha
|
||||
(converged deployment read from the deployment authority). The
|
||||
distinction is exposed honestly, never papered over (§6).
|
||||
|
||||
RELEASE RANGE for version N: (tag of previous version, tag N] PLUS
|
||||
same-version convergence commits up to the deployed identity (§9/§10).
|
||||
|
||||
PUBLICATION (§15): Gitea API POST /releases to create, PATCH /releases/{id}
|
||||
to update. Idempotent (§17): publishing twice for the same tag UPDATES the
|
||||
existing release — never duplicates. Notes carry provenance (§20-21):
|
||||
historical release documentation, NOT Memory and NOT structural authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = [
|
||||
"ReleaseNote",
|
||||
"extract_evidence_facts",
|
||||
"build_note",
|
||||
"render_markdown",
|
||||
"publish_release",
|
||||
"fetch_release",
|
||||
"converged_deployed_sha",
|
||||
]
|
||||
|
||||
# ── structured record (§13) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ReleaseNote:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
summary: str,
|
||||
improved: list[str] | None = None,
|
||||
added: list[str] | None = None,
|
||||
fixed: list[str] | None = None,
|
||||
proven: list[str] | None = None,
|
||||
limitations: list[str] | None = None,
|
||||
references: list[str] | None = None,
|
||||
release_sha: str = "",
|
||||
deployed_sha: str = "",
|
||||
provenance: list[str] | None = None,
|
||||
backfill: bool = False,
|
||||
):
|
||||
self.version = version
|
||||
self.summary = summary
|
||||
self.improved = sorted(improved or [])
|
||||
self.added = sorted(added or [])
|
||||
self.fixed = sorted(fixed or [])
|
||||
self.proven = sorted(proven or [])
|
||||
self.limitations = sorted(limitations or [])
|
||||
self.references = sorted(references or [])
|
||||
self.release_sha = release_sha
|
||||
self.deployed_sha = deployed_sha
|
||||
self.provenance = sorted(provenance or [])
|
||||
self.backfill = backfill
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"version": self.version,
|
||||
"summary": self.summary,
|
||||
"improved": self.improved,
|
||||
"added": self.added,
|
||||
"fixed": self.fixed,
|
||||
"proven": self.proven,
|
||||
"limitations": self.limitations,
|
||||
"references": self.references,
|
||||
"release_sha": self.release_sha,
|
||||
"deployed_sha": self.deployed_sha,
|
||||
"provenance": self.provenance,
|
||||
"backfill": self.backfill,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ReleaseNote":
|
||||
return cls(**d)
|
||||
|
||||
|
||||
def render_markdown(note: ReleaseNote) -> str:
|
||||
"""Human-readable Markdown (§7/§11): concise sections, empty sections
|
||||
omitted, evidence references kept, version/deployed distinction shown."""
|
||||
lines = [f"# Rune v{note.version}", "", note.summary, ""]
|
||||
if note.release_sha and note.deployed_sha:
|
||||
if note.release_sha[:12] == note.deployed_sha[:12]:
|
||||
lines.append(f"_Release commit: `{note.release_sha[:12]}` (deployed: `{note.deployed_sha[:12]}`)_")
|
||||
else:
|
||||
lines.append(
|
||||
f"_Tag commit: `{note.release_sha[:12]}` · "
|
||||
f"**final deployed: `{note.deployed_sha[:12]}`** "
|
||||
f"(same-version convergence commits included)_"
|
||||
)
|
||||
elif note.deployed_sha:
|
||||
lines.append(f"_Deployed: `{note.deployed_sha[:12]}`_")
|
||||
lines.append("")
|
||||
sections = [
|
||||
("Improved", note.improved),
|
||||
("Added", note.added),
|
||||
("Fixed", note.fixed),
|
||||
("Proven", note.proven),
|
||||
("Known limitations", note.limitations),
|
||||
("References", note.references),
|
||||
]
|
||||
for title, items in sections:
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"## {title}")
|
||||
for item in items:
|
||||
lines.append(f"- {item}")
|
||||
lines.append("")
|
||||
if note.backfill:
|
||||
lines.append("_Historical backfill: derived from surviving governed evidence after the fact._")
|
||||
if note.provenance:
|
||||
lines.append("_Derived from: " + "; ".join(note.provenance) + "_")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
# ── evidence extraction (§8/§24) ─────────────────────────────────────────────
|
||||
|
||||
_CONTRACT_EVIDENCE = "Construction Contract"
|
||||
_MAINT_EVIDENCE = "maintenance item"
|
||||
_DEPLOY_EVIDENCE = "deployment record"
|
||||
_LOCK_EVIDENCE = "dependency-lock record"
|
||||
|
||||
|
||||
def extract_evidence_facts(scaffold_doc: dict) -> dict:
|
||||
"""Deterministic fact extraction from governed records (§8). Returns the
|
||||
raw fact pool the note builder selects from. Commit messages are NOT a
|
||||
source here — governed records only."""
|
||||
facts: dict = {"improved": [], "added": [], "fixed": [], "proven": [], "limitations": [], "references": []}
|
||||
for e in scaffold_doc.get("entities", []):
|
||||
ident = e.get("identities", {})
|
||||
if e["kind"] == "construction_contract" and e.get("status") == "closed":
|
||||
facts["references"].append(f"{e['id']} — {ident.get('need', '')[:90]}")
|
||||
q = e.get("ext", {}).get("contract_quality", {})
|
||||
if q.get("correctly_predicted"):
|
||||
facts["proven"].append(
|
||||
f"{e['id']}: predicted effects independently confirmed ({len(q['correctly_predicted'])} entities)"
|
||||
)
|
||||
elif e["kind"] == "maintenance_item":
|
||||
status = ident.get("status")
|
||||
issue = ident.get("issue", "")[:90]
|
||||
if status == "resolved":
|
||||
facts["fixed"].append(f"{e['id']}: {issue}")
|
||||
elif status == "open":
|
||||
facts["limitations"].append(f"{e['id']}: {issue}")
|
||||
elif e["kind"] == "tui_projection_decision":
|
||||
# recorded projection decisions are release-relevant facts
|
||||
facts["references"].append(f"TUI projection for {ident.get('feature', '')}: {ident.get('projection', '')}")
|
||||
# contract-quality declared maturity evidence
|
||||
for e in scaffold_doc.get("entities", []):
|
||||
if e["kind"] == "construction_contract" and e.get("ext", {}).get("release_evidence"):
|
||||
rel = e["ext"]["release_evidence"]
|
||||
if isinstance(rel, dict) and rel.get("final_deployed") or rel.get("deployed_identity"):
|
||||
facts["proven"].append(
|
||||
f"{e['id']}: release chain proven ({rel.get('final_deployed') or rel.get('deployed_identity')})"
|
||||
)
|
||||
for k in facts:
|
||||
facts[k] = sorted(set(facts[k]))
|
||||
return facts
|
||||
|
||||
|
||||
def build_note(
|
||||
*,
|
||||
version: str,
|
||||
summary: str,
|
||||
scaffold_doc: dict,
|
||||
release_sha: str = "",
|
||||
deployed_sha: str = "",
|
||||
extra: dict | None = None,
|
||||
provenance: list[str] | None = None,
|
||||
backfill: bool = False,
|
||||
exclude_unsupported: list[str] | None = None,
|
||||
) -> ReleaseNote:
|
||||
"""Assemble a ReleaseNote from the governed fact pool + explicit extras
|
||||
(extra items must themselves be evidence-backed — the caller carries the
|
||||
responsibility). `exclude_unsupported` drops fact entries containing any
|
||||
of the given substrings — the §24F unsupported-claim rejection surface."""
|
||||
facts = extract_evidence_facts(scaffold_doc)
|
||||
if exclude_unsupported:
|
||||
|
||||
def _clean(items: list[str]) -> list[str]:
|
||||
return [i for i in items if not any(x.lower() in i.lower() for x in exclude_unsupported)]
|
||||
|
||||
for k in facts:
|
||||
facts[k] = _clean(facts[k])
|
||||
merged = {}
|
||||
for k in ("improved", "added", "fixed", "proven", "limitations", "references"):
|
||||
# §24F: extras pass through the SAME unsupported-claim filter as
|
||||
# extracted facts — a caller cannot smuggle a retracted claim in
|
||||
# via `extra` either.
|
||||
extras = [
|
||||
i for i in (extra or {}).get(k, []) if not any(x.lower() in i.lower() for x in (exclude_unsupported or []))
|
||||
]
|
||||
merged[k] = sorted(set(facts[k]) | set(extras))
|
||||
return ReleaseNote(
|
||||
version=version,
|
||||
summary=summary,
|
||||
release_sha=release_sha,
|
||||
deployed_sha=deployed_sha,
|
||||
provenance=provenance or [],
|
||||
backfill=backfill,
|
||||
**merged,
|
||||
)
|
||||
|
||||
|
||||
# ── stored-body field extraction (§14B single authority) ─────────────────────
|
||||
# One set of parsers for release-body fields, shared by release_history_cli.py
|
||||
# and deploy_stage's authority-derived publisher — never duplicated.
|
||||
|
||||
|
||||
def summary_from_body(body: str) -> str:
|
||||
"""Extract the summary paragraph (between the H1 and the first other
|
||||
heading/italic line) from a stored release body."""
|
||||
lines = body.splitlines()
|
||||
grab = []
|
||||
for ln in lines[1:]:
|
||||
s = ln.strip()
|
||||
if not s:
|
||||
if grab:
|
||||
break
|
||||
continue
|
||||
if s.startswith(("#", "_", "##", "-")):
|
||||
break
|
||||
grab.append(s)
|
||||
return " ".join(grab) or f"Rune — evidence-derived release history"
|
||||
|
||||
|
||||
def sha_from_body(body: str, which: str) -> str:
|
||||
"""Extract a recorded SHA from a stored release body: 'tag' → the tag
|
||||
commit; anything else → the final deployed identity."""
|
||||
pat = ("tag commit: `([0-9a-f]+)`" if which == "tag"
|
||||
else r"\*\*final deployed: `([0-9a-f]+)`\*\*")
|
||||
m = re.search(pat, body, re.I)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
# ── Gitea publication (§15/§17/§25) ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _api(url: str, token: str, method: str = "GET", payload: dict | None = None) -> tuple[int, dict]:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
method=method,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
||||
data=json.dumps(payload).encode() if payload else None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
body = r.read()
|
||||
return r.status, (json.loads(body) if body else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
text = body.decode("utf-8", "replace")
|
||||
try:
|
||||
return e.code, (json.loads(text) if text else {})
|
||||
except ValueError:
|
||||
# Gitea returns plain-text bodies for some errors (e.g. 404
|
||||
# "page not found" on unknown release-tag lookups)
|
||||
return e.code, {"_raw": text[:200]}
|
||||
except OSError as e:
|
||||
return 0, {"_error": str(e)[:200]}
|
||||
|
||||
|
||||
def fetch_release(server: str, repo: str, token: str, tag: str) -> dict | None:
|
||||
status, data = _api(f"{server}/api/v1/repos/{repo}/releases/tags/{tag}", token)
|
||||
return data if status == 200 else None
|
||||
|
||||
|
||||
def publish_release(server: str, repo: str, token: str, note: ReleaseNote, *, target_commitish: str = "master") -> dict:
|
||||
"""Create or idempotently update the Gitea release for note.version
|
||||
(§17): existing release → PATCH body (same evidence converges, never
|
||||
duplicates); missing release → POST create with the tag target. The tag
|
||||
itself is owned by auto-version — this module never rewrites tags (§30.5).
|
||||
Returns {action: created|updated, release_id, html_url?} or an error
|
||||
dict with `failed: True` (§16 — publication failure is visible)."""
|
||||
tag = f"v{note.version}"
|
||||
body = render_markdown(note)
|
||||
existing = fetch_release(server, repo, token, tag)
|
||||
if existing is not None:
|
||||
status, data = _api(
|
||||
f"{server}/api/v1/repos/{repo}/releases/{existing['id']}",
|
||||
token,
|
||||
"PATCH",
|
||||
{"body": body, "name": f"Rune v{note.version}"},
|
||||
)
|
||||
if status != 200:
|
||||
return {
|
||||
"failed": True,
|
||||
"action": "update",
|
||||
"status": status,
|
||||
"error": data.get("_error") or data.get("message", "")[:200],
|
||||
}
|
||||
return {"action": "updated", "release_id": data.get("id"), "html_url": data.get("html_url", "")}
|
||||
status, data = _api(
|
||||
f"{server}/api/v1/repos/{repo}/releases",
|
||||
token,
|
||||
"POST",
|
||||
{
|
||||
"tag_name": tag,
|
||||
"target_commitish": target_commitish,
|
||||
"name": f"Rune v{note.version}",
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
},
|
||||
)
|
||||
if status not in (200, 201):
|
||||
return {
|
||||
"failed": True,
|
||||
"action": "create",
|
||||
"status": status,
|
||||
"error": data.get("_error") or data.get("message", "")[:200],
|
||||
}
|
||||
return {"action": "created", "release_id": data.get("id"), "html_url": data.get("html_url", "")}
|
||||
|
||||
|
||||
def converged_deployed_sha(deploy_root: str = "/mnt/workdrive/rune-code") -> str:
|
||||
"""Read the converged deployed commit from the deployment authority
|
||||
(the `current` symlink's build identity) — never assumed equal to the
|
||||
tag SHA (§6)."""
|
||||
try:
|
||||
target = Path(os_readlink(Path(deploy_root) / "current"))
|
||||
identity = json.loads((target / "src" / "rune" / "_build_identity.json").read_text(encoding="utf-8"))
|
||||
return str(identity.get("commit", ""))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def os_readlink(p: Path) -> Path:
|
||||
import os
|
||||
|
||||
return Path(os.readlink(p))
|
||||
|
||||
|
||||
# ── automatic lifecycle entry points (Tranche 9B §4/§7) ─────────────────────
|
||||
|
||||
def auto_initial_note(*, version: str, summary: str, scaffold_store: str,
|
||||
release_sha: str, deployed_sha: str = "",
|
||||
provenance: list[str] | None = None) -> ReleaseNote:
|
||||
"""RELEASE INITIALIZATION: build evidence-derived notes with NO
|
||||
hand-written extra prose (§7). Facts come deterministically from the
|
||||
governed scaffold store — contracts closed, maintenance resolved/open,
|
||||
quality records. Routine releases require nothing from the builder."""
|
||||
try:
|
||||
doc = json.loads(Path(scaffold_store).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
doc = {"entities": [], "relationships": [], "observations": []}
|
||||
facts = extract_evidence_facts(doc)
|
||||
return ReleaseNote(
|
||||
version=version, summary=summary,
|
||||
improved=[], added=[], fixed=[],
|
||||
proven=facts["proven"], limitations=facts["limitations"],
|
||||
references=facts["references"],
|
||||
release_sha=release_sha, deployed_sha=deployed_sha,
|
||||
provenance=provenance or ["automatic release initialization "
|
||||
"(auto-version workflow)"])
|
||||
|
||||
|
||||
def convergence_refresh(existing_note: ReleaseNote, *,
|
||||
deployed_sha: str,
|
||||
scaffold_store: str,
|
||||
extra_provenance: str = "") -> ReleaseNote:
|
||||
"""RELEASE CONVERGENCE REFRESH: a later SHA of the SAME version became
|
||||
deployment:current — refresh the SAME note with the new deployed
|
||||
identity and any newly-governed facts (§3/§10). Tag/release SHA never
|
||||
changes; evidence accumulates; idempotent by construction (same inputs
|
||||
→ same note)."""
|
||||
try:
|
||||
doc = json.loads(Path(scaffold_store).read_text(encoding="utf-8"))
|
||||
facts = extract_evidence_facts(doc)
|
||||
except (OSError, ValueError):
|
||||
facts = {"proven": [], "limitations": [], "references": []}
|
||||
prov = list(existing_note.provenance)
|
||||
if extra_provenance and extra_provenance not in prov:
|
||||
prov.append(extra_provenance)
|
||||
return ReleaseNote(
|
||||
version=existing_note.version, summary=existing_note.summary,
|
||||
improved=existing_note.improved, added=existing_note.added,
|
||||
fixed=existing_note.fixed,
|
||||
proven=sorted(set(existing_note.proven) | set(facts["proven"])),
|
||||
limitations=sorted(set(existing_note.limitations)
|
||||
| set(facts["limitations"])),
|
||||
references=sorted(set(existing_note.references)
|
||||
| set(facts["references"])),
|
||||
release_sha=existing_note.release_sha, # tag SHA NEVER changes
|
||||
deployed_sha=deployed_sha, # convergence truth
|
||||
provenance=prov, backfill=existing_note.backfill)
|
||||
|
||||
|
||||
def build_from_deployment(*, version: str, deployed_sha: str,
|
||||
scaffold_store: str,
|
||||
release_sha: str = "",
|
||||
summary: str = "") -> ReleaseNote:
|
||||
"""Deploy-side convergence entry: rebuild/refresh notes from the
|
||||
CURRENT governed store + the deployment authority's identity. No
|
||||
hand-written prose required."""
|
||||
note = auto_initial_note(
|
||||
version=version,
|
||||
summary=summary or f"Rune v{version} — evidence-derived release "
|
||||
f"history (converged deployment)",
|
||||
scaffold_store=scaffold_store, release_sha=release_sha,
|
||||
deployed_sha=deployed_sha,
|
||||
provenance=["automatic convergence refresh (deploy workflow)"])
|
||||
return note
|
||||
+123
-8
@@ -751,6 +751,93 @@ class RuneOperator:
|
||||
"tasks_completed": len([t for t in self.state.tasks if t.status == "completed"]),
|
||||
}
|
||||
|
||||
def _construction_store(self) -> str:
|
||||
"""SCAFFOLD-TRANCHE-0012: construction store location. Derives from
|
||||
the module's own repo root (rune_operator.py lives in <root>/src/rune/)
|
||||
with an env override for tests/fixtures — never a bare relative path
|
||||
dependent on the caller's CWD."""
|
||||
env = os.environ.get("RUNE_CONSTRUCTION_STORE")
|
||||
if env:
|
||||
return env
|
||||
return str(Path(__file__).resolve().parent.parent.parent
|
||||
/ "store" / "scaffold")
|
||||
|
||||
def _construction_note(self, text: str) -> None:
|
||||
"""SCAFFOLD-TRANCHE-0012: append a construction activation note to
|
||||
the governed scaffold observations. Degrades silently — observations
|
||||
are evidence, never task-critical."""
|
||||
try:
|
||||
from .scaffold import Scaffold
|
||||
s = Scaffold(self._construction_store(), "issue161-harness")
|
||||
doc = s.load()
|
||||
doc.setdefault("observations", []).append({
|
||||
"kind": "construction_note", "text": text[:300],
|
||||
"observed_at": __import__("datetime").datetime.utcnow()
|
||||
.strftime("%Y-%m-%dT%H:%M:%SZ")})
|
||||
s.save(doc)
|
||||
except Exception: # noqa: S110 — notes are best-effort by design
|
||||
pass
|
||||
|
||||
def _construction_activation(self, description: str, task_id: str) -> None:
|
||||
"""SCAFFOLD-TRANCHE-0011/0012 §11: contextual construction
|
||||
classification + activation on the NORMAL input path. Deterministic;
|
||||
the mode lands in the durable scaffold store where the shared
|
||||
Construction Context (and the future TUI) reads it. Delegates all
|
||||
lifecycle decisions to construction_mode/construction_activation —
|
||||
NO duplicate intelligence here."""
|
||||
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()
|
||||
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.
|
||||
|
||||
@@ -808,6 +895,24 @@ class RuneOperator:
|
||||
channel=channel,
|
||||
)
|
||||
task.route = task_route.route if task_route else ""
|
||||
|
||||
# CONTEXTUAL CONSTRUCTION ACTIVATION (SCAFFOLD-TRANCHE-0011/0012):
|
||||
# every normal input passes through here. Classification is
|
||||
# deterministic; activation degrades gracefully — a construction
|
||||
# failure NEVER blocks the ordinary task path (same lifecycle
|
||||
# guarantee as the route above).
|
||||
try:
|
||||
self._construction_activation(description, state_task.task_id)
|
||||
except Exception as exc: # noqa: BLE001 — construction must not break tasks
|
||||
try:
|
||||
self._task_state.transition(
|
||||
state_task.task_id, "running",
|
||||
outcome_ref="construction_activation_error")
|
||||
except Exception: # noqa: S110 — secondary degrade, task continues
|
||||
pass
|
||||
self._construction_note(
|
||||
f"construction activation error: {exc}")
|
||||
|
||||
if task_route is not None and task_route.decided_at:
|
||||
# Canonical reference: task state POINTS at the route event in
|
||||
# route.jsonl (sole route authority) — no value copied.
|
||||
@@ -1228,6 +1333,22 @@ class RuneOperator:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _transport_for(model: str, timeout: float = 300.0):
|
||||
"""Provider-prefix transport selection — ONE authority for both the
|
||||
direct turn and the approval-resume path (T15 substrate contract).
|
||||
|
||||
Any model with a non-ollama provider prefix rides the [OI]-compat
|
||||
wire; ollama-prefixed and bare names keep the Ollama path. Observed
|
||||
live (PL1 Task A, 2026-09-12): the resume path hardcoded
|
||||
OllamaChatTransport, so the post-approval follow-up call hit
|
||||
localhost with a cloud model name → 400 'invalid model name'."""
|
||||
from rune.openai_compat_transport import cloud_transport_for, is_cloud_pinned
|
||||
|
||||
if is_cloud_pinned(model):
|
||||
return cloud_transport_for(model, timeout=timeout)
|
||||
return OllamaChatTransport(timeout=timeout)
|
||||
|
||||
def _runtime_for_model(self, model: str, task_description: str = "") -> LocalToolRuntime:
|
||||
"""Build the runtime used to continue a persisted approval turn.
|
||||
|
||||
@@ -1250,7 +1371,7 @@ class RuneOperator:
|
||||
if not tools:
|
||||
tools = list(tool_executor.TOOLS) # pre-#24 behavior (degrade)
|
||||
return LocalToolRuntime(
|
||||
transport=OllamaChatTransport(),
|
||||
transport=self._transport_for(model),
|
||||
executor=tool_executor.execute_tool,
|
||||
model=model,
|
||||
tools=tools,
|
||||
@@ -1967,13 +2088,7 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
|
||||
# routing_mode condition and 400'd. Deterministic — any model
|
||||
# with a non-ollama provider prefix rides the OpenAI-compat
|
||||
# wire; ollama-prefixed and bare names keep the proven path.
|
||||
_prov_prefix = model.split("/", 1)[0].strip().lower() if "/" in model else ""
|
||||
if _prov_prefix and _prov_prefix not in ("ollama", "local"):
|
||||
from rune.openai_compat_transport import cloud_transport_for
|
||||
|
||||
transport = cloud_transport_for(model, timeout=timeout)
|
||||
else:
|
||||
transport = OllamaChatTransport(timeout=timeout)
|
||||
transport = self._transport_for(model, timeout=timeout)
|
||||
runtime = LocalToolRuntime(
|
||||
transport=transport,
|
||||
executor=tool_executor.execute_tool,
|
||||
|
||||
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.66.17
|
||||
Version: v0.66.68
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1856,7 +1856,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.66.17 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.66.68 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
"""scaffold.py — Project Scaffold: construction/structural truth for a
|
||||
project Rune builds, maintains, or evolves.
|
||||
|
||||
Thin layer over the shared structural-store primitive (structural_store.py).
|
||||
NO graph engine here — the store, maturity ladder, authority reconciliation,
|
||||
alias/supersession, frontier and diff all come from the shared core.
|
||||
|
||||
Project Scaffold is CANONICAL for the project's own construction/structural
|
||||
state. It is NOT Rune Spine: project internals never enter Rune's store.
|
||||
Rune Spine holds one `project:<stable-id>` entity with builds/manages
|
||||
relationships and the connector surface — the bridge, not a mirror.
|
||||
|
||||
Repository/forge binding: the scaffold records repo IDENTITY (forge,
|
||||
owner/name, branch, root, CI workflow identities) and repo OBSERVATIONS
|
||||
(HEAD, clean/dirty, last verification verdict). It never stores credentials;
|
||||
it never duplicates Gitea coordination state — issues/PRs/workflows are
|
||||
coordination surfaces, referenced as evidence, not copied as structure.
|
||||
|
||||
Repository placement model (decision, not automation):
|
||||
RUNE_REPO shares Rune's lifecycle/release/versioning
|
||||
EXISTING_PROJECT_REPO work lands in an already-established repo
|
||||
NEW_MANAGED_REPO standalone lifecycle warrants its own repo
|
||||
(user approval required to create one)
|
||||
|
||||
Construction Contract: ONE durable object per bounded build. `phase`
|
||||
(draft→approved→building→verifying→migrating→superseding→closed) and
|
||||
`status` (open|closed|failed|abandoned) are plain fields governed by a
|
||||
small legal-transition map — a workflow engine only if reality demands one.
|
||||
Resumability = durability of the store (proven by cross-process tests).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from rune.structural_store import ( # shared generic core — no Rune constants
|
||||
SCHEMA_VERSION,
|
||||
add_entity,
|
||||
add_relationship,
|
||||
bind_task,
|
||||
diff,
|
||||
entity,
|
||||
impact_frontier,
|
||||
load,
|
||||
new_doc,
|
||||
now,
|
||||
predicted_vs_observed,
|
||||
record_observed_impact,
|
||||
reconcile_authorities,
|
||||
relationships,
|
||||
resolve_status,
|
||||
save,
|
||||
snapshot,
|
||||
structural_events,
|
||||
supersede,
|
||||
task_entities,
|
||||
)
|
||||
|
||||
__all__ = ["Scaffold", "PHASES", "LEGAL_TRANSITIONS", "REPO_PLACEMENTS", "REPO_SECRET_FIELDS"]
|
||||
|
||||
# ── constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
PHASES = ["draft", "approved", "building", "verifying", "migrating", "superseding", "closed", "failed", "abandoned"]
|
||||
LEGAL_TRANSITIONS = {
|
||||
"draft": {"approved", "abandoned"},
|
||||
"approved": {"building", "abandoned"},
|
||||
"building": {"verifying", "approved", "abandoned", "failed"},
|
||||
"verifying": {"migrating", "building", "failed", "abandoned", "closed"},
|
||||
"migrating": {"superseding", "verifying", "failed", "abandoned"},
|
||||
"superseding": {"closed", "failed", "abandoned"},
|
||||
"closed": set(),
|
||||
"failed": {"building", "abandoned"}, # retry path
|
||||
"abandoned": set(),
|
||||
}
|
||||
STATUSES = {"open", "closed", "failed", "abandoned"}
|
||||
REPO_PLACEMENTS = {"RUNE_REPO", "EXISTING_PROJECT_REPO", "NEW_MANAGED_REPO"}
|
||||
REPO_SECRET_FIELDS = {"token", "api_key", "password", "secret", "auth_header", "credential", "api_token"}
|
||||
|
||||
|
||||
class IllegalPhaseTransition(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class Scaffold:
|
||||
"""One Project Scaffold: a durable structural/construction store rooted
|
||||
at `store_dir` (typically `<project>/.rune/scaffold/`)."""
|
||||
|
||||
def __init__(self, store_dir: Path | str, project_id: str):
|
||||
self.store_dir = Path(store_dir)
|
||||
self.project_id = project_id
|
||||
self.current = self.store_dir / "current.json"
|
||||
self.snapshots_dir = self.store_dir / "snapshots"
|
||||
|
||||
# -- store lifecycle ----------------------------------------------------
|
||||
|
||||
def init(self, *, purpose: str, requirement: str = "") -> dict:
|
||||
"""Initialize (or load) this project's scaffold and register the
|
||||
project identity entity."""
|
||||
doc = load(self.current)
|
||||
if doc is None:
|
||||
doc = new_doc()
|
||||
add_entity(
|
||||
doc,
|
||||
id=f"project:{self.project_id}",
|
||||
kind="project",
|
||||
identities={"purpose": purpose, "requirement": requirement},
|
||||
maturity="DECLARED",
|
||||
evidence_refs=["scaffold-init"],
|
||||
)
|
||||
save(doc, self.current)
|
||||
return doc
|
||||
|
||||
def load(self) -> dict:
|
||||
doc = load(self.current)
|
||||
if doc is None:
|
||||
doc = new_doc()
|
||||
return doc
|
||||
|
||||
def save(self, doc: dict) -> Path:
|
||||
return save(doc, self.current)
|
||||
|
||||
def save_verified(self, doc: dict, selector, expect, *, what: str = "state") -> Path:
|
||||
"""Save + RELOAD-READBACK confirmation (SCAFFOLD-TRANCHE-006 §22A).
|
||||
Catches DISK-WRITE failures: the in-memory doc is saved, then the
|
||||
store is reloaded from disk and checked. This confirms the write
|
||||
landed — it cannot catch a mutation that was never saved, because
|
||||
saving is what this method does. For the forgotten-save check use
|
||||
verify_on_disk (no write)."""
|
||||
p = self.save(doc)
|
||||
reloaded = self.load()
|
||||
value = selector(reloaded)
|
||||
ok = expect(value) if callable(expect) else value == expect
|
||||
if not ok:
|
||||
raise AssertionError(
|
||||
f"persistence verification FAILED for {what!r}: reloaded "
|
||||
f"store does not reflect the saved doc (disk-write failure)"
|
||||
)
|
||||
return p
|
||||
|
||||
def commit(self, doc: dict, *, what: str = "durable state",
|
||||
selector=None, expect=None) -> Path:
|
||||
"""§21 TRANSACTIONAL BOUNDARY (Tranche 11): the ONLY way durable
|
||||
mutations are finalized. Persists, then verifies by readback. All
|
||||
accounting/report work happens AFTER commit returns — no quality
|
||||
inspection may sit between mutation and its durable commit (the
|
||||
forgotten-save anti-pattern: mutate → fragile read → exception →
|
||||
save never happens, 3 recurrences T5/T9B/T10)."""
|
||||
# §12B REAL DURABLE COMMIT SEMANTICS: "load didn't throw" is not
|
||||
# proof — load() returns a fresh empty document when the store file
|
||||
# is missing/corrupt, which silently masquerades as success. Instead:
|
||||
# canonical digest of the INTENDED document vs canonical digest of
|
||||
# what is ACTUALLY on disk after save. Semantic mismatch (partial
|
||||
# write, wrong path, unreadable store) fails visibly.
|
||||
from rune.structural_store import canonical_digest
|
||||
from rune.structural_store import load as _ss_load
|
||||
|
||||
intended = canonical_digest(doc)
|
||||
p = self.save(doc)
|
||||
reloaded = _ss_load(self.current)
|
||||
if reloaded is None:
|
||||
raise AssertionError(
|
||||
f"persistence verification FAILED for {what!r}: store did "
|
||||
f"not reload after save (missing or corrupt)")
|
||||
if canonical_digest(reloaded) != intended:
|
||||
raise AssertionError(
|
||||
f"persistence verification FAILED for {what!r}: reloaded "
|
||||
f"store digest differs from intended document — the "
|
||||
f"mutation is not durably what was intended")
|
||||
# optional caller-supplied semantic invariant (held-out: wrong/
|
||||
# stale doc where an expectation was supplied)
|
||||
if selector is not None:
|
||||
value = selector(reloaded)
|
||||
ok = expect(value) if callable(expect) else value == expect
|
||||
if not ok:
|
||||
raise AssertionError(
|
||||
f"persistence invariant FAILED for {what!r}: reloaded "
|
||||
f"state does not satisfy the supplied expectation")
|
||||
return p
|
||||
|
||||
def verify_on_disk(self, selector, expect, *, what: str = "state") -> bool:
|
||||
"""PURE READBACK guard (SCAFFOLD-TRANCHE-006 §22A, the actual
|
||||
Tranche 5 defect guard): reload the store from disk WITHOUT saving
|
||||
and check the expectation. An in-memory mutation that was never
|
||||
persisted fails here — the disk state is what a future session
|
||||
would see. This is the forgotten-save lie detector."""
|
||||
reloaded = self.load()
|
||||
value = selector(reloaded)
|
||||
ok = expect(value) if callable(expect) else value == expect
|
||||
if not ok:
|
||||
raise AssertionError(
|
||||
f"durable-state verification FAILED for {what!r}: the on-"
|
||||
f"disk store does not reflect the claimed mutation — it was "
|
||||
f"probably never saved (Tranche 5 defect class A)"
|
||||
)
|
||||
return True
|
||||
|
||||
# -- project structure (open-world kinds; no fixed vocabulary) ----------
|
||||
|
||||
def add_domain(self, doc: dict, name: str, description: str = "") -> dict:
|
||||
return add_entity(
|
||||
doc, id=f"domain:{name}", kind="domain", identities={"description": description}, evidence_refs=["scaffold"]
|
||||
)
|
||||
|
||||
def add_component(
|
||||
self,
|
||||
doc: dict,
|
||||
name: str,
|
||||
*,
|
||||
kind: str = "component",
|
||||
path: str = "",
|
||||
description: str = "",
|
||||
maturity: str = "DECLARED",
|
||||
) -> dict:
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"component:{name}",
|
||||
kind=kind,
|
||||
identities={"path": path, "description": description},
|
||||
maturity=maturity,
|
||||
evidence_refs=["scaffold"],
|
||||
)
|
||||
|
||||
def add_contract(self, doc: dict, name: str, *, description: str = "", spec_ref: str = "") -> dict:
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"contract:{name}",
|
||||
kind="contract",
|
||||
identities={"description": description, "spec": spec_ref},
|
||||
evidence_refs=["scaffold"],
|
||||
)
|
||||
|
||||
def link(self, doc: dict, *, type: str, from_id: str, to_id: str, evidence_refs=None) -> dict:
|
||||
"""Generic relationship with evidence (contains/depends_on/consumes/
|
||||
exposes/serves/route_to/... — open type vocabulary)."""
|
||||
return add_relationship(
|
||||
doc, type=type, from_id=from_id, to_id=to_id, evidence_refs=evidence_refs or ["scaffold"]
|
||||
)
|
||||
|
||||
def set_prd(self, doc: dict, prd_ref: str) -> dict:
|
||||
"""Record the PRD/location reference (NOT a copy of the PRD)."""
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"prd:{self.project_id}",
|
||||
kind="prd_state",
|
||||
identities={"reference": prd_ref},
|
||||
evidence_refs=["scaffold"],
|
||||
)
|
||||
|
||||
def set_next_action(self, doc: dict, action: str) -> dict:
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"next_action:{self.project_id}",
|
||||
kind="next_action",
|
||||
identities={"action": action},
|
||||
evidence_refs=["scaffold"],
|
||||
)
|
||||
|
||||
def add_limitation(self, doc: dict, name: str, detail: str = "") -> dict:
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"limitation:{name}",
|
||||
kind="known_limitation",
|
||||
identities={"detail": detail},
|
||||
evidence_refs=["scaffold"],
|
||||
)
|
||||
|
||||
def add_maintenance_item(
|
||||
self,
|
||||
doc: dict,
|
||||
name: str,
|
||||
*,
|
||||
issue: str,
|
||||
evidence: str = "",
|
||||
benefit: str = "",
|
||||
approval: str = "user",
|
||||
priority: str = "normal",
|
||||
status: str = "open",
|
||||
) -> dict:
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"maintenance:{name}",
|
||||
kind="maintenance_item",
|
||||
identities={
|
||||
"issue": issue,
|
||||
"evidence": evidence,
|
||||
"benefit": benefit,
|
||||
"approval": approval,
|
||||
"priority": priority,
|
||||
"status": status,
|
||||
},
|
||||
evidence_refs=["scaffold"],
|
||||
)
|
||||
|
||||
# -- repository / forge binding ------------------------------------------
|
||||
|
||||
def bind_repo(
|
||||
self,
|
||||
doc: dict,
|
||||
*,
|
||||
forge: str,
|
||||
owner: str,
|
||||
name: str,
|
||||
default_branch: str,
|
||||
repo_root: str,
|
||||
placement: str = "EXISTING_PROJECT_REPO",
|
||||
worktree_strategy: str = "per-contract",
|
||||
ci_workflows: list[str] | None = None,
|
||||
deployment_ref: str = "",
|
||||
version_tag_policy: str = "",
|
||||
issue_integration: str = "",
|
||||
rationale: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Record repo IDENTITY + placement decision. NEVER credentials —
|
||||
the repo_identity schema rejects secret-named fields (enforced below
|
||||
and by tests). Observations (HEAD, dirty state) are separate."""
|
||||
if placement not in REPO_PLACEMENTS:
|
||||
raise ValueError(f"unknown repo placement: {placement}")
|
||||
identities = {
|
||||
"forge": forge,
|
||||
"owner": owner,
|
||||
"name": name,
|
||||
"full_name": f"{owner}/{name}",
|
||||
"default_branch": default_branch,
|
||||
"repo_root": repo_root,
|
||||
"worktree_strategy": worktree_strategy,
|
||||
"ci_workflows": sorted(ci_workflows or []),
|
||||
"deployment_ref": deployment_ref,
|
||||
"version_tag_policy": version_tag_policy,
|
||||
"issue_integration": issue_integration,
|
||||
"placement": placement,
|
||||
"rationale": sorted(rationale or []),
|
||||
}
|
||||
bad = REPO_SECRET_FIELDS & set(identities)
|
||||
if bad:
|
||||
raise ValueError(f"repo binding rejects secret-named fields: {bad}")
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"repo:{owner}/{name}",
|
||||
kind="repo_identity",
|
||||
identities=identities,
|
||||
maturity="DECLARED",
|
||||
evidence_refs=["repo-binding"],
|
||||
)
|
||||
|
||||
def observe_repo(
|
||||
self,
|
||||
doc: dict,
|
||||
owner: str,
|
||||
name: str,
|
||||
*,
|
||||
head: str = "",
|
||||
clean: bool | None = None,
|
||||
last_verification: str = "",
|
||||
) -> dict:
|
||||
"""Record a repo STATE observation (HEAD, dirty flag, verdict).
|
||||
Observations are refreshable; stale ones never read as current
|
||||
(resolve_status handles that)."""
|
||||
rid = f"repo:{owner}/{name}"
|
||||
e = entity(doc, rid)
|
||||
if e is None:
|
||||
raise ValueError(f"bind_repo first: {rid} not found")
|
||||
obs = {"head": head[:12], "clean": clean, "last_verification": last_verification}
|
||||
e.setdefault("ext", {})["observed_state"] = obs
|
||||
e["observed_at"] = now()
|
||||
doc["observations"].append(
|
||||
{"kind": "repo_observed", "repo": rid, "head": head[:12], "clean": clean, "observed_at": now()}
|
||||
)
|
||||
return e
|
||||
|
||||
def repo_state_from_git(self, doc: dict, owner: str, name: str, repo_root: str) -> dict:
|
||||
"""Refresh repo observation from the actual git working tree
|
||||
(deterministic; reality wins)."""
|
||||
root = Path(repo_root)
|
||||
head = ""
|
||||
clean = None
|
||||
try:
|
||||
head = subprocess.run(
|
||||
["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True, timeout=15
|
||||
).stdout.strip()
|
||||
st = subprocess.run(
|
||||
["git", "-C", str(root), "status", "--short"], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
clean = st.returncode == 0 and st.stdout.strip() == ""
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass # observation degrades gracefully; stale flag governs
|
||||
return self.observe_repo(doc, owner, name, head=head, clean=clean)
|
||||
|
||||
# -- construction contracts ----------------------------------------------
|
||||
|
||||
def new_contract(
|
||||
self,
|
||||
doc: dict,
|
||||
*,
|
||||
contract_id: str,
|
||||
need: str,
|
||||
origin: str = "user",
|
||||
scope: list[str] | None = None,
|
||||
requirement: str = "",
|
||||
gap_evidence: str = "",
|
||||
research_refs: list[str] | None = None,
|
||||
resolution: str = "",
|
||||
toolchain: dict | None = None,
|
||||
affected_entities: list[str] | None = None,
|
||||
expected_consumers: list[str] | None = None,
|
||||
regression_obligations: list[str] | None = None,
|
||||
verification_obligations: list[str] | None = None,
|
||||
migration_obligations: list[str] | None = None,
|
||||
expected_new_entities: list[str] | None = None,
|
||||
rollback_ref: str = "",
|
||||
acceptance: list[str] | None = None,
|
||||
non_goals: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a Construction Contract in `draft` phase. One durable
|
||||
object; phases/status are plain fields with a legal-transition map.
|
||||
`toolchain` is a data seam: language/runtime/framework/libs/rationale
|
||||
— no Python-specific assumptions enforced.
|
||||
`expected_new_entities` (SCAFFOLD-TRANCHE-004 contract-quality
|
||||
repair): entities this build PLANS to create. At closure these are
|
||||
checked against observed reality — an observed planned-new entity is
|
||||
NOT a prediction miss (the Tranche 3 defect this field fixes)."""
|
||||
if entity(doc, f"contract:{contract_id}") is not None:
|
||||
raise ValueError(f"contract already exists: {contract_id}")
|
||||
identities = {
|
||||
"need": need,
|
||||
"origin": origin,
|
||||
"scope": sorted(scope or []),
|
||||
"requirement": requirement,
|
||||
"gap_evidence": gap_evidence,
|
||||
"research_refs": sorted(research_refs or []),
|
||||
"resolution": resolution,
|
||||
"toolchain": dict(toolchain or {}), # data seam: any language/runtime
|
||||
"affected_entities": sorted(affected_entities or []),
|
||||
"expected_consumers": sorted(expected_consumers or []),
|
||||
"expected_new_entities": sorted(expected_new_entities or []),
|
||||
"regression_obligations": sorted(regression_obligations or []),
|
||||
"verification_obligations": sorted(verification_obligations or []),
|
||||
"migration_obligations": sorted(migration_obligations or []),
|
||||
"rollback_ref": rollback_ref,
|
||||
"acceptance": sorted(acceptance or []),
|
||||
"non_goals": sorted(non_goals or []),
|
||||
"next_action": "review for approval",
|
||||
}
|
||||
e = add_entity(
|
||||
doc,
|
||||
id=f"contract:{contract_id}",
|
||||
kind="construction_contract",
|
||||
identities=identities,
|
||||
maturity="DECLARED",
|
||||
evidence_refs=[f"contract-created:{contract_id}"],
|
||||
)
|
||||
e["phase"] = "draft"
|
||||
e["status"] = "open"
|
||||
e["checkpoints"] = []
|
||||
return e
|
||||
|
||||
def transition_contract(
|
||||
self, doc: dict, contract_id: str, phase: str, *, evidence_ref: str = "", note: str = ""
|
||||
) -> dict:
|
||||
"""Move a contract to a new phase through the legal-transition map.
|
||||
Durable and resumable: the phase lives in the store."""
|
||||
e = entity(doc, f"contract:{contract_id}")
|
||||
if e is None:
|
||||
raise ValueError(f"contract not found: {contract_id}")
|
||||
current = e.get("phase", "draft")
|
||||
if phase not in PHASES:
|
||||
raise ValueError(f"unknown phase: {phase}")
|
||||
if phase not in LEGAL_TRANSITIONS.get(current, set()):
|
||||
raise IllegalPhaseTransition(
|
||||
f"illegal transition: {current} → {phase} (legal: {sorted(LEGAL_TRANSITIONS.get(current, set()))})"
|
||||
)
|
||||
e["phase"] = phase
|
||||
if phase == "closed":
|
||||
e["status"] = "closed"
|
||||
elif phase == "failed":
|
||||
e["status"] = "failed"
|
||||
e.setdefault("checkpoints", []).append(
|
||||
{"phase": phase, "at": now(), "evidence": evidence_ref, "note": note[:200]}
|
||||
)
|
||||
e["identities"]["next_action"] = _NEXT_ACTION.get(phase, "")
|
||||
e["observed_at"] = now()
|
||||
return e
|
||||
|
||||
def contract(self, doc: dict, contract_id: str) -> dict | None:
|
||||
return entity(doc, f"contract:{contract_id}")
|
||||
|
||||
def snapshot_predicted_frontier(self, doc: dict, contract_id: str, seed_entity_ids: list[str]) -> dict:
|
||||
"""Persist the predicted impact frontier at approval time — the input
|
||||
to closure's predicted-vs-observed comparison."""
|
||||
frontier = impact_frontier(doc, seed_entity_ids)
|
||||
e = entity(doc, f"contract:{contract_id}")
|
||||
if e is None:
|
||||
raise ValueError(f"contract not found: {contract_id}")
|
||||
e.setdefault("ext", {})["predicted_frontier"] = frontier
|
||||
e.setdefault("checkpoints", []).append(
|
||||
{
|
||||
"phase": e.get("phase", "draft"),
|
||||
"at": now(),
|
||||
"evidence": "predicted-frontier",
|
||||
"note": json_frontier(frontier),
|
||||
}
|
||||
)
|
||||
return frontier
|
||||
|
||||
def close_contract(
|
||||
self,
|
||||
doc: dict,
|
||||
contract_id: str,
|
||||
*,
|
||||
observed_evidence: list[dict] | None = None,
|
||||
scope_files: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Close a contract: record observed impact independently, run the
|
||||
predicted-vs-observed comparison, persist Contract Quality evidence.
|
||||
Requires the contract to be in `superseding` phase (the final work
|
||||
phase) or `verifying` (pure-observation builds).
|
||||
|
||||
SCAFFOLD-TRANCHE-004 contract-quality repair (§16-18): closure
|
||||
distinguishes
|
||||
predicted_existing_affected — planned changes to EXISTING entities
|
||||
expected_new — entities the build PLANNED to create
|
||||
observed_existing — predicted entities evidence confirms
|
||||
observed_new — newly observed surfaces
|
||||
correctly_predicted — predicted existing AND observed
|
||||
predicted_but_unaffected — predicted existing, NOT observed
|
||||
expected_new_observed — planned creation happened
|
||||
expected_new_missing — planned creation did NOT happen
|
||||
genuinely_unexpected_new — observed, planned by NEITHER list
|
||||
Prediction and observation remain independently sourced: the
|
||||
predicted sets come from the approval-time frontier/contract fields;
|
||||
the observed set comes ONLY from the caller's evidence items
|
||||
(git/tests/build/deploy) — never from re-deriving the prediction.
|
||||
Legacy keys preserved: `missed` aliases genuinely_unexpected_new,
|
||||
`needless` aliases predicted_but_unaffected (old contracts readable)."""
|
||||
e = entity(doc, f"contract:{contract_id}")
|
||||
if e is None:
|
||||
raise ValueError(f"contract not found: {contract_id}")
|
||||
if e.get("phase") not in ("superseding", "verifying"):
|
||||
raise IllegalPhaseTransition(f"close requires superseding/verifying phase, got {e.get('phase')}")
|
||||
predicted = e.get("ext", {}).get("predicted_frontier")
|
||||
affected = e.get("identities", {}).get("affected_entities", [])
|
||||
expected_new = set(e.get("identities", {}).get("expected_new_entities", []))
|
||||
# Tranche 5 §14: expected RUNTIME effects (deployment:current advances
|
||||
# etc.) are prediction/accounting metadata, not workflow steps. An
|
||||
# observed entity matching an expected runtime effect is EXPECTED,
|
||||
# not genuinely unexpected — but only when independently observed
|
||||
# (§15: prediction never self-certifies; the evidence item must exist).
|
||||
expected_runtime = set(e.get("ext", {}).get("expected_runtime_effects", []))
|
||||
quality: dict = {"contract": contract_id, "captured_at": now()}
|
||||
|
||||
# independent observed impact (§17): evidence items only
|
||||
obs = record_observed_impact(doc, contract_id, observed_evidence or [])
|
||||
obs_set = set(obs["observed_entities"])
|
||||
|
||||
pred_existing = set(e.get("ext", {}).get("predicted_existing_affected", []))
|
||||
pred_existing |= set(affected)
|
||||
if predicted is not None:
|
||||
pred_existing |= {
|
||||
x
|
||||
for k in ("level0_direct", "level1_obligations", "contracts", "level2_verification")
|
||||
for x in predicted.get(k, [])
|
||||
}
|
||||
|
||||
correctly_predicted = sorted(pred_existing & obs_set)
|
||||
predicted_but_unaffected = sorted(pred_existing - obs_set)
|
||||
expected_new_observed = sorted(expected_new & obs_set)
|
||||
expected_new_missing = sorted(expected_new - obs_set)
|
||||
# effect strings are phrases ("deployment:current advances"); an
|
||||
# observed entity matches when an expected effect STARTS with its id
|
||||
matched_runtime = {o for o in obs_set if any(eff.startswith(o) for eff in expected_runtime)}
|
||||
expected_runtime_observed = sorted(
|
||||
eff for eff in expected_runtime if any(eff.startswith(o) for o in matched_runtime)
|
||||
)
|
||||
expected_runtime_missing = sorted(expected_runtime - set(expected_runtime_observed))
|
||||
genuinely_unexpected_new = sorted(obs_set - pred_existing - expected_new - matched_runtime)
|
||||
|
||||
quality.update(
|
||||
{
|
||||
"predicted_existing_affected": sorted(pred_existing),
|
||||
"expected_new_entities": sorted(expected_new),
|
||||
"observed": sorted(obs_set), # legacy union (Tranche 2 readers)
|
||||
"observed_existing": correctly_predicted,
|
||||
"observed_new": sorted(obs_set & (expected_new | (obs_set - pred_existing))),
|
||||
"correctly_predicted": correctly_predicted,
|
||||
"predicted_but_unaffected": predicted_but_unaffected,
|
||||
"expected_new_observed": expected_new_observed,
|
||||
"expected_new_missing": expected_new_missing,
|
||||
"expected_runtime_effects": sorted(expected_runtime),
|
||||
"expected_runtime_observed": expected_runtime_observed,
|
||||
"expected_runtime_missing": sorted(expected_runtime - obs_set),
|
||||
"genuinely_unexpected_new": genuinely_unexpected_new,
|
||||
# legacy aliases (Tranche 2/3 consumers)
|
||||
"missed": genuinely_unexpected_new,
|
||||
"needless": predicted_but_unaffected,
|
||||
"candidate_corrections": [
|
||||
{
|
||||
"entity": x,
|
||||
"status": "CANDIDATE",
|
||||
"note": "genuinely unexpected new surface — evidence required before store update",
|
||||
}
|
||||
for x in genuinely_unexpected_new
|
||||
],
|
||||
"evidence_sources": sorted({item.get("source", "unattributed") for item in (observed_evidence or [])}),
|
||||
}
|
||||
)
|
||||
if scope_files is not None:
|
||||
quality["scope_check"] = self.check_scope(doc, contract_id, scope_files)
|
||||
e.setdefault("ext", {})["contract_quality"] = quality
|
||||
e.setdefault("checkpoints", []).append(
|
||||
{"phase": "closed", "at": now(), "evidence": "closure-record", "note": "predicted-vs-observed captured"}
|
||||
)
|
||||
e["phase"] = "closed"
|
||||
e["status"] = "closed"
|
||||
e["identities"]["next_action"] = _NEXT_ACTION["closed"]
|
||||
e["observed_at"] = now()
|
||||
return e
|
||||
|
||||
def check_scope(self, doc: dict, contract_id: str, changed_files: list[str]) -> dict:
|
||||
"""MECHANICAL SCOPE CHECK: changed files vs the contract's approved
|
||||
scope paths. Out-of-scope = RED (verification failure) unless the
|
||||
contract is amended. Deterministic; no code-ownership system."""
|
||||
e = entity(doc, f"contract:{contract_id}")
|
||||
if e is None:
|
||||
raise ValueError(f"contract not found: {contract_id}")
|
||||
scope = e.get("identities", {}).get("scope", [])
|
||||
if not scope:
|
||||
# An unscoped contract cannot prove scope-compliance: UNKNOWN,
|
||||
# not silently green. (Silent green = the silent-scope-expansion
|
||||
# failure mode this gate exists to prevent.)
|
||||
return {
|
||||
"verdict": "unknown",
|
||||
"in_scope": [],
|
||||
"out_of_scope": sorted(changed_files),
|
||||
"reason": "contract has no approved scope",
|
||||
}
|
||||
in_scope, out_scope = [], []
|
||||
for f in changed_files:
|
||||
ok = any(f == s or f.startswith(s.rstrip("/") + "/") or s in f for s in scope)
|
||||
(in_scope if ok else out_scope).append(f)
|
||||
verdict = "green" if not out_scope else "red"
|
||||
return {"verdict": verdict, "in_scope": sorted(in_scope), "out_of_scope": sorted(out_scope)}
|
||||
|
||||
# -- drift / reconciliation ----------------------------------------------
|
||||
|
||||
def reconcile(self, doc: dict) -> dict:
|
||||
"""Project-drift reconciliation: authority-owned staleness pass +
|
||||
observation dedup. Reality wins — stale facts are marked, not
|
||||
force-reverted."""
|
||||
staled = reconcile_authorities(doc)
|
||||
for s in staled:
|
||||
doc["observations"].append({"kind": "fact_staled", **s, "observed_at": now()})
|
||||
return {"staled": staled}
|
||||
|
||||
# -- layout obligation injection (SCAFFOLD-TRANCHE-004 §19-22) -------------
|
||||
|
||||
def layout_obligations(self, doc: dict, affected: list[str]) -> list[dict]:
|
||||
"""Query Layout Contracts relevant to a change's affected surface and
|
||||
emit build obligations the generated contract MUST inherit (§19).
|
||||
|
||||
`affected` may contain: `ui:<component>` ids, implementation paths
|
||||
(matched against contract identities.implementation), or region ids
|
||||
(a change to a region touches everything bound to it).
|
||||
|
||||
Semantics:
|
||||
FLEXIBLE contract → preserve/verify current contracted placement;
|
||||
movable via authorized amendment only.
|
||||
USER-LOCKED → placement is USER AUTHORITY: the build must
|
||||
preserve it, and the obligation states that
|
||||
changing it requires explicit user
|
||||
authorization (amend_layout_contract with
|
||||
authorized_by='user'). The live path NEVER
|
||||
reconciles user-locked drift by editing the
|
||||
contract (§20) — code adapts instead.
|
||||
No applicable contract → [] (unrelated backend changes inherit
|
||||
no layout obligations — tested).
|
||||
"""
|
||||
affected_set = set(affected)
|
||||
obligations = []
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "layout_contract" or e.get("status") in ("RETIRED",):
|
||||
continue
|
||||
ident = e["identities"]
|
||||
comp = ident.get("component", "")
|
||||
region = ident.get("region", "")
|
||||
impl = ident.get("implementation", "")
|
||||
region_hit = f"region:{region}" in affected_set
|
||||
comp_hit = comp in affected_set
|
||||
impl_hit = bool(impl) and any(impl.startswith(a) or a in impl for a in affected_set if a.endswith(".py"))
|
||||
if not (comp_hit or region_hit or impl_hit):
|
||||
continue
|
||||
locked = bool(ident.get("lock"))
|
||||
provenance = ident.get("provenance", "scaffold")
|
||||
if locked:
|
||||
obligation = (
|
||||
f"USER-LOCKED: {comp} placement in region '{region}' is "
|
||||
f"user authority — preserve it exactly; changing requires "
|
||||
f"explicit user authorization "
|
||||
f"(amend_layout_contract authorized_by='user')"
|
||||
)
|
||||
else:
|
||||
obligation = (
|
||||
f"preserve/verify {comp} contracted placement "
|
||||
f"(region '{region}', provenance {provenance}); movable "
|
||||
f"only via authorized layout amendment"
|
||||
)
|
||||
obligations.append(
|
||||
{
|
||||
"component": comp,
|
||||
"region": region,
|
||||
"lock": locked,
|
||||
"provenance": provenance,
|
||||
"obligation": obligation,
|
||||
"verification": "layout_contract_check must PASS"
|
||||
if not locked
|
||||
else "layout_contract_check must PASS; USER-LOCKED placement unchanged",
|
||||
"status": e.get("status", "ACTIVE"),
|
||||
}
|
||||
)
|
||||
return obligations
|
||||
|
||||
# -- bridge to Rune Spine (explicit, non-duplicating) ---------------------
|
||||
|
||||
def spine_bridge_entity(self) -> dict:
|
||||
"""The entity Rune Spine should hold for this project: identity +
|
||||
connector surface ONLY. Project internals (components/domains/routes)
|
||||
deliberately excluded — tested to prove no leakage."""
|
||||
doc = self.load()
|
||||
p = entity(doc, f"project:{self.project_id}")
|
||||
repo = next((e for e in doc["entities"] if e["kind"] == "repo_identity"), None)
|
||||
bridge = {
|
||||
"project": f"project:{self.project_id}",
|
||||
"purpose": (p or {}).get("identities", {}).get("purpose", ""),
|
||||
}
|
||||
if repo:
|
||||
bridge["repo_full_name"] = repo["identities"].get("full_name", "")
|
||||
bridge["repo_forge"] = repo["identities"].get("forge", "")
|
||||
return bridge
|
||||
|
||||
|
||||
def json_frontier(frontier: dict) -> str:
|
||||
import json
|
||||
|
||||
keys = ("level0_direct", "level1_obligations", "contracts", "level2_verification")
|
||||
return json.dumps({k: frontier.get(k, []) for k in keys})
|
||||
|
||||
|
||||
_NEXT_ACTION = {
|
||||
"draft": "review for approval",
|
||||
"approved": "begin building",
|
||||
"building": "complete implementation, then verify",
|
||||
"verifying": "run verification gates; resolve failures",
|
||||
"migrating": "migrate consumers/connectors",
|
||||
"superseding": "supersede old implementation; retain rollback",
|
||||
"closed": "observe real outcome; capture lessons",
|
||||
"failed": "diagnose; retry via building or abandon",
|
||||
"abandoned": "",
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
"""Operator surface — same convention as `rune-spine` (spine.main):
|
||||
plain argv, JSON output from the actual store. Never reconstructs
|
||||
state from prose."""
|
||||
import json as _json
|
||||
import sys as _sys
|
||||
|
||||
argv = list(_sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
cmd, args = argv[0], argv[1:]
|
||||
|
||||
# CONVENTION (all commands): [command args...] <store_dir> [project_id]
|
||||
# store_dir and project_id are ALWAYS the last two positional args, so
|
||||
# command-specific arguments never collide with the store location.
|
||||
def _scaffold_from_args() -> Scaffold:
|
||||
store_dir = args[-2] if len(args) >= 2 else ".rune/scaffold"
|
||||
project_id = args[-1] if args else Path(store_dir).name
|
||||
# guard: project-init passes [store, pid, purpose] — last two are
|
||||
# still store+pid, purpose is args[2] and handled locally
|
||||
return Scaffold(store_dir, project_id)
|
||||
|
||||
def _cmd_args() -> list[str]:
|
||||
"""Command-specific args = everything before the final two
|
||||
positional slots (store_dir, project_id)."""
|
||||
return args[:-2] if len(args) >= 2 else []
|
||||
|
||||
if cmd == "project-init":
|
||||
purpose = args[2] if len(args) > 2 else ""
|
||||
s = Scaffold(args[0] if args else ".rune/scaffold", args[1] if len(args) > 1 else "unnamed")
|
||||
doc = s.init(purpose=purpose)
|
||||
s.save(doc)
|
||||
print(_json.dumps({"project": f"project:{s.project_id}", "store": str(s.current)}))
|
||||
return 0
|
||||
if cmd == "project-show":
|
||||
s = _scaffold_from_args()
|
||||
doc = s.load()
|
||||
p = entity(doc, f"project:{s.project_id}")
|
||||
out = {
|
||||
"scaffold": str(s.current),
|
||||
"project": p,
|
||||
"bridge": s.spine_bridge_entity(),
|
||||
"entities": len(doc["entities"]),
|
||||
"relationships": len(doc["relationships"]),
|
||||
}
|
||||
print(_json.dumps(out, indent=1))
|
||||
return 0 if p else 1
|
||||
|
||||
doc = _scaffold_from_args().load()
|
||||
s = _scaffold_from_args()
|
||||
ca = _cmd_args()
|
||||
|
||||
if cmd == "contract-new":
|
||||
e = s.new_contract(doc, contract_id=ca[0], need=ca[1])
|
||||
s.save(doc)
|
||||
print(_json.dumps({"contract": f"contract:{ca[0]}", "phase": e["phase"], "status": e["status"]}))
|
||||
return 0
|
||||
if cmd == "contract-approve":
|
||||
s.transition_contract(doc, ca[0], "approved")
|
||||
s.save(doc)
|
||||
print(_json.dumps({"contract": ca[0], "phase": "approved"}))
|
||||
return 0
|
||||
if cmd == "contract-phase":
|
||||
s.transition_contract(doc, ca[0], ca[1])
|
||||
s.save(doc)
|
||||
print(_json.dumps({"contract": ca[0], "phase": ca[1]}))
|
||||
return 0
|
||||
if cmd == "contract-show":
|
||||
c = s.contract(doc, ca[0])
|
||||
print(_json.dumps(c, indent=1) if c else _json.dumps({"error": "not found", "contract": ca[0]}))
|
||||
return 0 if c else 1
|
||||
if cmd == "contract-verify-scope":
|
||||
v = s.check_scope(doc, ca[0], ca[1:])
|
||||
print(_json.dumps(v, indent=1))
|
||||
return 0 if v["verdict"] == "green" else 1
|
||||
if cmd == "contract-list":
|
||||
out = [
|
||||
{
|
||||
"id": e["id"],
|
||||
"phase": e.get("phase"),
|
||||
"status": e.get("status"),
|
||||
"next_action": e.get("identities", {}).get("next_action", ""),
|
||||
}
|
||||
for e in doc["entities"]
|
||||
if e["kind"] == "construction_contract"
|
||||
]
|
||||
print(_json.dumps(out, indent=1))
|
||||
return 0
|
||||
print(_json.dumps({"error": f"unknown command: {cmd}"}))
|
||||
return 2
|
||||
@@ -0,0 +1,695 @@
|
||||
"""spine.py — Rune Spine V1: evolvable structural self-model.
|
||||
|
||||
Spine answers, deterministically and without an LLM:
|
||||
what exists, how it relates, what is wired/live, what version is
|
||||
deployed, what evidence supports each claim, what drifted, what changed.
|
||||
|
||||
PRIME DIRECTIVE: model Rune's architecture without freezing it. Entity
|
||||
kinds, relationship types and metadata are OPEN TAGS — a previously unseen
|
||||
capability enters as data, never as a core edit. Unknown fields survive
|
||||
load/projection/serialize/reload untouched.
|
||||
|
||||
AUTHORITY MODEL: Spine is THE structural authority (subsystems, membership,
|
||||
dependencies, consumers, mirrors). Specialised authorities stay authoritative
|
||||
for their own domains and are OBSERVED, not copied: deployment identity
|
||||
(_build_identity.json), canary registry (canary_native.MODULES), provider
|
||||
config, CI outputs. Adding a domain = adding an adapter/projector.
|
||||
|
||||
MATURITY LADDER (evidence-gated, no evidence = no promotion):
|
||||
DECLARED < PRESENT < WIRED < REACHABLE < EXERCISED < LIVE_PROVEN
|
||||
DECLARED named in a registry/manifest
|
||||
PRESENT source artifact exists
|
||||
WIRED structural consumers/imports exist (or registry wiring)
|
||||
REACHABLE reachable from the composition root / deployed tree contains it
|
||||
EXERCISED test or canary exercises it
|
||||
LIVE_PROVEN runtime observation on the deployed identity proves it live
|
||||
|
||||
SNAPSHOTS: ingest is a pure function of authorities (sorted output), so
|
||||
snapshots are byte-deterministic; diff(a, b) answers "what changed" without
|
||||
interpretation.
|
||||
|
||||
Schema version: 1. Unknown metadata survives by construction; a future
|
||||
reader migrates by adding a projector, never by editing old records.
|
||||
|
||||
CLI: PYTHONPATH=src python3 -m rune.spine <command> (JSON canonical).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from rune.structural_store import ( # shared generic core (SCAFFOLD-TRANCHE-001)
|
||||
SCHEMA_VERSION, MATURITY_ORDER, MATURITY_RANK, REL_STATUS,
|
||||
NON_OBLIGATION_RELATIONSHIPS, FRONTIER_CONTRACT_EXPANSION, FRONTIER_LEVEL2_TYPES,
|
||||
now as _ss_now, _sorted as _ss_sorted, new_doc as new_doc, load as _ss_load,
|
||||
save as _ss_save, snapshot as _ss_snapshot, add_entity, add_relationship,
|
||||
relationships, promote, alias, supersede, spine_uri, resolve_uri, bind_task,
|
||||
task_entities, impact_frontier, record_observed_impact, predicted_vs_observed,
|
||||
structural_events, diff, _doc_authorities, _authority_observed, authority_failed,
|
||||
reconcile_authorities, mark_conflict, resolve_status, authority_health,
|
||||
dedupe_observations as _ss_dedupe_observations,
|
||||
)
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
MATURITY_ORDER = ["DECLARED", "PRESENT", "WIRED", "REACHABLE", "EXERCISED", "LIVE_PROVEN"]
|
||||
_MATURITY_RANK = {name: i for i, name in enumerate(MATURITY_ORDER)}
|
||||
|
||||
GENERATED_VIEW_MARKER = "_generated_by_spine"
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] # repo root (src/rune/spine.py → up 2)
|
||||
SRC_DIR = ROOT / "src" / "rune"
|
||||
TESTS_DIR = ROOT / "tests"
|
||||
STORE_DIR = ROOT / "store" / "spine"
|
||||
CURRENT = STORE_DIR / "current.json"
|
||||
SNAPSHOTS = STORE_DIR / "snapshots"
|
||||
# Deployment observation point (this machine's promoted build). Configurable:
|
||||
# never an identity, only an observation surface.
|
||||
DEPLOYED_LINK = Path(os.environ.get("RUNE_DEPLOYED_LINK", "/mnt/workdrive/rune-code/current"))
|
||||
UMBRELLA_MANIFEST = ROOT / "config" / "umbrellas.json"
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _sorted(obj):
|
||||
"""Deterministic normalization: sort everything sortable, recursively."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sorted(obj[k]) for k in sorted(obj)}
|
||||
if isinstance(obj, list):
|
||||
return sorted((_sorted(v) for v in obj), key=lambda v: json.dumps(v, sort_keys=True))
|
||||
return obj
|
||||
|
||||
|
||||
# ── store ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def load(path: Path | None = None) -> dict | None:
|
||||
p = path or CURRENT
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def save(doc: dict, path: Path | str | None = None) -> Path:
|
||||
incoming = doc.get("schema_version", SCHEMA_VERSION)
|
||||
if incoming != SCHEMA_VERSION:
|
||||
raise ValueError(f"schema_version {incoming} != {SCHEMA_VERSION}")
|
||||
doc["schema_version"] = SCHEMA_VERSION
|
||||
p = Path(path) if path else CURRENT # str paths are legitimate (CLI callers)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(_sorted(doc), indent=1, sort_keys=True) + "\n")
|
||||
return p
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── static projections (deterministic, from authorities) ─────────────────
|
||||
|
||||
def _module_imports(module: str) -> set[str]:
|
||||
path = SRC_DIR / f"{module}.py"
|
||||
try:
|
||||
tree = ast.parse(path.read_text())
|
||||
except (OSError, SyntaxError):
|
||||
return set()
|
||||
names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
names.add(a.name.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
names.add(node.module.split(".")[0])
|
||||
return names
|
||||
|
||||
|
||||
def _import_graph() -> dict[str, set[str]]:
|
||||
return {p.stem: _module_imports(p.stem) for p in SRC_DIR.glob("*.py")}
|
||||
|
||||
|
||||
def _canary_registry() -> list[str]:
|
||||
try:
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
from rune import canary_native
|
||||
return list(canary_native.MODULES)
|
||||
except ImportError:
|
||||
# canary_native pulls optional deps (sqlite_vec etc.); degrade to an
|
||||
# empty registry — drift will note absent registrations honestly.
|
||||
return []
|
||||
|
||||
|
||||
def _deployed_observation() -> dict | None:
|
||||
"""Observe the deployment authority — never invent it."""
|
||||
ident = DEPLOYED_LINK / "src" / "rune" / "_build_identity.json"
|
||||
if not ident.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(ident.read_text())
|
||||
return {"deployed_sha": data.get("commit", "")[:12],
|
||||
"deployed_version": data.get("version", ""),
|
||||
"deployed_at": data.get("deployed_at", ""),
|
||||
"observed_from": str(DEPLOYED_LINK)}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _test_backed_modules() -> set[str]:
|
||||
out = set()
|
||||
for p in TESTS_DIR.glob("test_*.py"):
|
||||
out.add(p.stem[5:])
|
||||
return out
|
||||
|
||||
|
||||
def ingest_umbrellas(doc: dict, manifest_path: Path | None = None) -> int:
|
||||
"""Phase A: translate the (last manually maintained) umbrella manifest
|
||||
into Spine entities + relationships. Refuses a Spine-GENERATED view —
|
||||
that guards the ingest cycle after Phase C makes the view output-only.
|
||||
Returns count of subsystems ingested."""
|
||||
path = manifest_path or UMBRELLA_MANIFEST
|
||||
text = path.read_text()
|
||||
data = json.loads(text)
|
||||
if GENERATED_VIEW_MARKER in data:
|
||||
raise ValueError(
|
||||
f"{path} is a Spine-generated compatibility view; refusing to "
|
||||
"ingest it (Phase A reads the manual authority exactly once). "
|
||||
"Structural membership now lives in the Spine store.")
|
||||
count = 0
|
||||
KNOWN_FIELDS = {"description", "modules", "depends_on", "mirrors", "canaries"}
|
||||
for name, u in data.get("umbrellas", {}).items():
|
||||
sid = f"subsystem:{name}"
|
||||
# canaries ride as ext data (obligation metadata, not structure);
|
||||
# unknown future manifest fields ride too — open-world preservation
|
||||
ext = {k: u[k] for k in u if k in ("canaries",) or k not in KNOWN_FIELDS}
|
||||
add_entity(doc, id=sid, kind="subsystem",
|
||||
identities={"description": u.get("description", "")},
|
||||
evidence_refs=[f"umbrella-manifest#{name}"],
|
||||
maturity="DECLARED", ext=ext or None)
|
||||
count += 1
|
||||
for dep in u.get("depends_on", []):
|
||||
add_relationship(doc, type="depends_on", from_id=sid,
|
||||
to_id=f"subsystem:{dep}",
|
||||
evidence_refs=[f"umbrella-manifest#{name}"])
|
||||
for mirror in u.get("mirrors", []):
|
||||
add_relationship(doc, type="mirrors", from_id=sid, to_id=f"file:{mirror}",
|
||||
evidence_refs=[f"umbrella-manifest#{name}"])
|
||||
for canary in u.get("canaries", []):
|
||||
add_relationship(doc, type="obligated_canary", from_id=sid,
|
||||
to_id=f"module:{canary}",
|
||||
evidence_refs=[f"umbrella-manifest#{name}"])
|
||||
for mod in u.get("modules", []):
|
||||
mid = f"module:{mod}"
|
||||
add_entity(doc, id=mid, kind="module", tags=["code"],
|
||||
identities={"source": f"src/rune/{mod}.py"},
|
||||
evidence_refs=[f"umbrella-manifest#{name}"],
|
||||
maturity="DECLARED")
|
||||
add_relationship(doc, type="belongs_to", from_id=mid, to_id=sid,
|
||||
evidence_refs=[f"umbrella-manifest#{name}"])
|
||||
return count
|
||||
|
||||
|
||||
def build(manifest_path: Path | None = None) -> dict:
|
||||
"""Deterministic full projection from authorities. Same inputs →
|
||||
byte-identical doc (modulo generated_at)."""
|
||||
doc = new_doc()
|
||||
|
||||
# 1. subsystem structure — from the Spine store if it already owns
|
||||
# membership (Phase B+), else from the manual manifest (Phase A).
|
||||
existing = load()
|
||||
seeded = False
|
||||
if existing:
|
||||
rels = [r for r in existing["relationships"] if r["type"] == "belongs_to"]
|
||||
if rels:
|
||||
seeded = True
|
||||
# CARRY EVERYTHING FORWARD — open-world requirement. Filtering
|
||||
# by known kinds/types here would silently destroy tasks,
|
||||
# contracts, capabilities, and any future entity class on every
|
||||
# refresh (the exact TOO-CLOSED failure the contract forbids;
|
||||
# observed live: task bindings vanished through build()).
|
||||
# Module-level facts (imports, tests, maturity) are RE-DERIVED
|
||||
# below onto the carried state; everything else passes through.
|
||||
doc["entities"].extend(json.loads(json.dumps(existing["entities"])))
|
||||
doc["relationships"].extend(json.loads(json.dumps(existing["relationships"])))
|
||||
doc["observations"].extend(json.loads(json.dumps(
|
||||
existing.get("observations", []))))
|
||||
if not seeded:
|
||||
ingest_umbrellas(doc, manifest_path)
|
||||
|
||||
# 2. module-level structural facts from actual code
|
||||
graph = _import_graph()
|
||||
registry = _canary_registry()
|
||||
tested = _test_backed_modules()
|
||||
mods = {e["id"].split(":", 1)[1] for e in doc["entities"] if e["kind"] == "module"}
|
||||
for m in sorted(mods):
|
||||
mid = f"module:{m}"
|
||||
e = entity(doc, mid)
|
||||
# Re-derivation uses the HIGH-WATER path (add_entity upgrades only,
|
||||
# never logs): fresh evidence may be lower than the store's earned
|
||||
# state on any given pass, and that is not a demotion event — it is
|
||||
# a build. Explicit promote() logging is for evidence-gated API calls.
|
||||
def _derive(state: str, ev: str) -> None:
|
||||
add_entity(doc, id=mid, kind="module", maturity=state,
|
||||
evidence_refs=[ev])
|
||||
|
||||
if (SRC_DIR / f"{m}.py").is_file():
|
||||
assert e is not None # membership loop guarantees presence
|
||||
e["identities"]["source"] = f"src/rune/{m}.py"
|
||||
_derive("PRESENT", f"source:{m}.py")
|
||||
consumers = sorted(c for c, deps in graph.items() if m in deps and c != m)
|
||||
deps = sorted(d for d in graph.get(m, set()) if d in mods and d != m)
|
||||
for c in consumers:
|
||||
rel = add_relationship(doc, type="consumed_by", from_id=mid,
|
||||
to_id=f"module:{c}", evidence_refs=[f"ast-import:{m}"])
|
||||
_authority_observed(doc, rel, "ast-import-projector")
|
||||
for d in deps:
|
||||
rel = add_relationship(doc, type="depends_on", from_id=mid,
|
||||
to_id=f"module:{d}", evidence_refs=[f"ast-import:{m}"])
|
||||
_authority_observed(doc, rel, "ast-import-projector")
|
||||
# WIRED: importers exist, or registry wiring (canary registry)
|
||||
if consumers or m in registry:
|
||||
_derive("WIRED", f"ast-imports:{len(consumers)}" if consumers
|
||||
else "canary-registry")
|
||||
# EXERCISED: dedicated test or canary exercise
|
||||
if m in tested:
|
||||
_derive("EXERCISED", f"tests/test_{m}.py")
|
||||
elif m in registry:
|
||||
_derive("EXERCISED", "canary-registry-exercise")
|
||||
|
||||
# 3. runtime/deployment observation (dynamic, bounded)
|
||||
obs = _deployed_observation()
|
||||
if obs:
|
||||
_authority_observed(doc, None, "deploy-identity-projector") # generation bump
|
||||
dep = add_entity(doc, id="deployment:current", kind="deployment_surface",
|
||||
identities=obs, evidence_refs=["_build_identity.json"],
|
||||
maturity="LIVE_PROVEN")
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] == "module" and (DEPLOYED_LINK / e["identities"].get("source", "")).is_file():
|
||||
rel = add_relationship(doc, type="deployed_as", from_id=e["id"],
|
||||
to_id="deployment:current",
|
||||
evidence_refs=[f"deployed-tree:{obs['deployed_sha']}"])
|
||||
_authority_observed(doc, rel, "deploy-identity-projector")
|
||||
doc["observations"].append({"kind": "deployed_identity", **obs, "observed_at": now()})
|
||||
|
||||
# reconcile: facts owned by projectors that no longer observe them
|
||||
# become STALE (marked, never deleted — history survives; open-world
|
||||
# preservation applies to everything, staleness is the truth marker)
|
||||
staled = reconcile_authorities(doc)
|
||||
for s in staled:
|
||||
doc["observations"].append({"kind": "fact_staled", **s, "observed_at": now()})
|
||||
|
||||
# exact-duplicate observation suppression (carry-forward + this pass
|
||||
# both record deployed_identity etc.); distinct repeats are history
|
||||
seen_obs = set()
|
||||
unique_obs = []
|
||||
for o in doc["observations"]:
|
||||
key = json.dumps(_sorted(o), sort_keys=True)
|
||||
if key not in seen_obs:
|
||||
seen_obs.add(key)
|
||||
unique_obs.append(o)
|
||||
doc["observations"] = unique_obs
|
||||
doc["generated_at"] = now()
|
||||
return doc
|
||||
|
||||
|
||||
# ── drift ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def drift(doc: dict) -> list[dict]:
|
||||
"""Architectural drift classes. Reuses authorities' verdicts; never
|
||||
duplicates their logic. Deterministic output."""
|
||||
out = []
|
||||
graph = _import_graph()
|
||||
registry = _canary_registry()
|
||||
tested = _test_backed_modules()
|
||||
mods = {e["id"].split(":", 1)[1] for e in doc["entities"] if e["kind"] == "module"}
|
||||
|
||||
# 1. registered but absent (canary registry names a non-existent module)
|
||||
for m in registry:
|
||||
if m not in mods:
|
||||
out.append({"class": "registered_but_absent", "entity": f"module:{m}",
|
||||
"detail": "in canary registry, no source file"})
|
||||
|
||||
# 2. present but unowned (runtime source file outside every subsystem).
|
||||
# Excludes canary files (governed by the canary contract, not umbrella
|
||||
# membership) and package plumbing (__init__/__main__).
|
||||
for p in SRC_DIR.glob("*.py"):
|
||||
if p.stem.startswith(("canary_", "__")):
|
||||
continue
|
||||
if p.stem not in mods:
|
||||
out.append({"class": "present_but_unowned", "entity": f"module:{p.stem}",
|
||||
"detail": "src/rune runtime module with no subsystem membership"})
|
||||
|
||||
# 3. registered but no static importers — OBSERVATION, not a death
|
||||
# verdict: registry dispatch and entry points wire dynamically
|
||||
# (task_gate via capability_broker, rune_tui via entry point).
|
||||
# Interpretation belongs to cognition; Spine reports the fact.
|
||||
for m in sorted(mods & set(registry)):
|
||||
importers = [c for c, deps in graph.items() if m in deps and c != m]
|
||||
if not importers:
|
||||
dyn = "no dedicated test either" if m not in tested else "dedicated test exists"
|
||||
out.append({"class": "registered_no_static_importers", "entity": f"module:{m}",
|
||||
"detail": f"zero static importers; dynamic wiring possible; {dyn}"})
|
||||
|
||||
# 4. deployed identity drift (source version vs deployment authority)
|
||||
obs = _deployed_observation()
|
||||
try:
|
||||
src_version = (ROOT / "pyproject.toml").read_text()
|
||||
import re
|
||||
m = re.search(r'^version\s*=\s*"([^"]+)"', src_version, re.M)
|
||||
src_version = m.group(1) if m else ""
|
||||
except OSError:
|
||||
src_version = ""
|
||||
if obs and src_version and obs["deployed_version"] != src_version:
|
||||
out.append({"class": "version_surfaces_disagree",
|
||||
"detail": f"source {src_version} != deployed {obs['deployed_version']}"})
|
||||
|
||||
# 4b. evidence staleness (adapter freshness): maturity claims whose
|
||||
# evidence references predate the current store generation are STALE
|
||||
# observations, never silently current — "not checked" != "healthy".
|
||||
for e in doc["entities"]:
|
||||
if e.get("maturity") in ("EXERCISED", "LIVE_PROVEN") and not e.get("evidence_refs"):
|
||||
out.append({"class": "evidence_conflict", "entity": e["id"],
|
||||
"detail": f"claims {e['maturity']} with zero evidence refs"})
|
||||
|
||||
# 4c. stale facts — owned by an authority that no longer observes them
|
||||
for rel in doc["relationships"]:
|
||||
if rel.get("status") == "STALE":
|
||||
out.append({"class": "stale_fact", "entity": f"{rel['from']}->{rel['to']}",
|
||||
"detail": f"{rel['type']} edge; authority {rel.get('authority')} "
|
||||
"no longer observes it"})
|
||||
|
||||
# 5. relationship disappeared (store claims consumers that no longer import)
|
||||
for r in relationships(doc, type="consumed_by"):
|
||||
m = r["from"].split(":", 1)[1]
|
||||
c = r["to"].split(":", 1)[1]
|
||||
if m not in graph.get(c, set()):
|
||||
out.append({"class": "relationship_disappeared",
|
||||
"detail": f"{c} no longer imports {m} (stale store edge)"})
|
||||
return out
|
||||
|
||||
|
||||
# ── snapshots / deltas ────────────────────────────────────────────────────
|
||||
|
||||
def snapshot(doc: dict, label: str = "") -> Path:
|
||||
SNAPSHOTS.mkdir(parents=True, exist_ok=True)
|
||||
obs = _deployed_observation() or {}
|
||||
name = f"snap-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}-{obs.get('deployed_sha', 'noident')[:8]}"
|
||||
if label:
|
||||
name += f"-{label}"
|
||||
p = SNAPSHOTS / f"{name}.json"
|
||||
save(doc, p)
|
||||
return p
|
||||
|
||||
|
||||
|
||||
# ── compatibility projection (Phase C) ────────────────────────────────────
|
||||
|
||||
def export_umbrellas(doc: dict) -> dict:
|
||||
"""Generate the legacy umbrella view FROM Spine. Output only — the old
|
||||
consumers (CI gate, henxels) read this during migration. Never ingest."""
|
||||
umbrellas: dict = {}
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "subsystem":
|
||||
continue
|
||||
name = e["id"].split(":", 1)[1]
|
||||
umbrellas[name] = {"description": e["identities"].get("description", ""),
|
||||
"modules": [], "depends_on": [], "mirrors": []}
|
||||
# faithful reproduction: emit canaries only when the ingested
|
||||
# authority carried them (the legacy contract omits empty lists)
|
||||
if e.get("ext", {}).get("canaries"):
|
||||
umbrellas[name]["canaries"] = sorted(e["ext"]["canaries"])
|
||||
for r in doc["relationships"]:
|
||||
if r["type"] == "belongs_to" and r["from"].startswith("module:"):
|
||||
sub = r["to"].split(":", 1)[1]
|
||||
if sub in umbrellas:
|
||||
umbrellas[sub]["modules"].append(r["from"].split(":", 1)[1])
|
||||
elif r["type"] == "depends_on" and r["from"].startswith("subsystem:"):
|
||||
sub = r["from"].split(":", 1)[1]
|
||||
if sub in umbrellas:
|
||||
umbrellas[sub]["depends_on"].append(r["to"].split(":", 1)[1])
|
||||
elif r["type"] == "mirrors" and r["from"].startswith("subsystem:"):
|
||||
sub = r["from"].split(":", 1)[1]
|
||||
if sub in umbrellas:
|
||||
umbrellas[sub]["mirrors"].append(r["to"].split(":", 1)[1])
|
||||
elif r["type"] == "obligated_canary" and r["from"].startswith("subsystem:"):
|
||||
sub = r["from"].split(":", 1)[1]
|
||||
if sub in umbrellas:
|
||||
umbrellas[sub]["canaries"].append(r["to"].split(":", 1)[1])
|
||||
for u in umbrellas.values():
|
||||
u["modules"].sort(); u["depends_on"].sort(); u["mirrors"].sort()
|
||||
if "canaries" in u:
|
||||
u["canaries"].sort()
|
||||
return {"_comment": "GENERATED by rune.spine — compatibility view for legacy "
|
||||
"umbrella consumers. DO NOT EDIT: structural truth lives in "
|
||||
"store/spine/current.json; edits here are flagged by spine drift.",
|
||||
GENERATED_VIEW_MARKER: True,
|
||||
"generated_at": now(),
|
||||
"umbrellas": dict(sorted(umbrellas.items()))}
|
||||
|
||||
|
||||
# ── contracts, task binding, impact frontier (T22 + graph addenda) ────────
|
||||
#
|
||||
# GRAPH SEPARATION: Spine is its own LOGICAL graph — current structural
|
||||
# truth, not memory. The frontier traverses Spine edges ONLY. Memory-style
|
||||
# associations (hypotheses, history, lessons) must never expand structural
|
||||
# impact: they are not in this graph, and non-obligation relationship
|
||||
# classes are excluded by the semantics table below. Cross-linking happens
|
||||
# by stable spine:// URIs — a future Memory record references a Spine
|
||||
# entity without duplicating it; structural edges stay Spine-authoritative.
|
||||
|
||||
NON_OBLIGATION_RELATIONSHIPS = {
|
||||
# Data-driven semantics table — NOT a closed ontology: unknown types
|
||||
# default to obligation-bearing (fail-loud), listed types are known
|
||||
# informational edges that must not explode a change frontier.
|
||||
"documented_by", "historical_relation", "related_memory",
|
||||
"supersedes", # supersession is context, not a live obligation
|
||||
}
|
||||
|
||||
# Relationships whose TRAVERSAL creates LEVEL-1 frontier entries, per
|
||||
# direction: type → ("outgoing" edges from X, "incoming" edges into X)
|
||||
FRONTIER_LEVEL1 = {
|
||||
"consumed_by": "outgoing", # X is consumed by → consumers
|
||||
"depends_on": "outgoing", # X depends on → its dependencies
|
||||
"implements": "outgoing", # X implements contract → the contract
|
||||
"consumes": "outgoing", # X consumes contract → the contract
|
||||
"belongs_to": "outgoing", # ownership context
|
||||
"deployed_as": "outgoing", # deployment surface
|
||||
}
|
||||
FRONTIER_CONTRACT_EXPANSION = {"implements", "consumes"}
|
||||
# LEVEL 2: verification surfaces attached to frontier entities
|
||||
FRONTIER_LEVEL2_TYPES = {"verified_by", "obligated_canary"}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def query(doc: dict, entity_id: str) -> dict:
|
||||
"""INTRINSIC SELF-KNOWLEDGE: one deterministic call answering the
|
||||
representative structural question set. No LLM, no Memory, no rediscovery."""
|
||||
e = entity(doc, entity_id)
|
||||
if e is None:
|
||||
return {"error": "not found", "id": entity_id}
|
||||
return {
|
||||
"id": entity_id,
|
||||
"uri": spine_uri(entity_id),
|
||||
"kind": e["kind"],
|
||||
"owner": [r["to"] for r in relationships(doc, from_id=entity_id, type="belongs_to")],
|
||||
"dependencies": sorted({r["to"] for r in relationships(doc, from_id=entity_id, type="depends_on")}),
|
||||
"consumers": sorted({r["to"] for r in relationships(doc, from_id=entity_id, type="consumed_by")}
|
||||
| {r["from"] for r in relationships(doc, to_id=entity_id, type="consumed_by")}),
|
||||
"contracts_implements": sorted(r["to"] for r in relationships(doc, from_id=entity_id, type="implements")),
|
||||
"contracts_consumes": sorted(r["to"] for r in relationships(doc, from_id=entity_id, type="consumes")),
|
||||
"state": {"maturity": e.get("maturity"), "observed_at": e.get("observed_at"),
|
||||
"resolved_status": resolve_status(e),
|
||||
"superseded_by": e.get("superseded_by"),
|
||||
"aliases": e.get("aliases", [])},
|
||||
"evidence": e.get("evidence_refs", []),
|
||||
"impact_frontier": impact_frontier(doc, [entity_id]),
|
||||
}
|
||||
|
||||
|
||||
# ── authority/projector ownership + reconciliation (hardening T3/T4) ──────
|
||||
#
|
||||
# A projected fact carries `authority` = the projector that owns it. On
|
||||
# refresh, each authority's pass records a generation and the facts it still
|
||||
# observes. Facts the owner no longer emits become STALE — marked, reported
|
||||
# in drift, excluded from frontiers — never silently current, never silently
|
||||
# deleted (history survives). Facts of OTHER authorities (or manual facts
|
||||
# with no authority) are never touched by a foreign refresh: the
|
||||
# reconciliation boundary is AUTHORITY OWNERSHIP, not type knowledge.
|
||||
|
||||
REL_STATUS = {"UNKNOWN", "CURRENT", "STALE", "CONFLICTING", "DEGRADED"}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── durable identity: rename / alias / supersession (hardening T5) ────────
|
||||
|
||||
|
||||
|
||||
def entity(doc: dict, id: str) -> dict | None:
|
||||
doc_ents = doc.get("entities", [])
|
||||
for e in doc_ents:
|
||||
if e["id"] == id:
|
||||
return e
|
||||
# rename-resilient resolution: old spine:// references stay meaningful
|
||||
for e in doc_ents:
|
||||
if id in e.get("aliases", []):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _fresh_doc() -> dict:
|
||||
doc = load()
|
||||
if doc is None:
|
||||
doc = build()
|
||||
save(doc)
|
||||
return doc
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
cmd = argv[0]
|
||||
args = argv[1:]
|
||||
|
||||
if cmd == "ingest": # Phase A: seed from manual manifest (explicit)
|
||||
doc = new_doc()
|
||||
n = ingest_umbrellas(doc, Path(args[0]) if args else None)
|
||||
save(doc)
|
||||
print(json.dumps({"ingested_subsystems": n, "store": str(CURRENT)}))
|
||||
return 0
|
||||
if cmd == "build": # full deterministic projection
|
||||
doc = build()
|
||||
save(doc)
|
||||
print(json.dumps({"entities": len(doc["entities"]),
|
||||
"relationships": len(doc["relationships"]),
|
||||
"observations": len(doc["observations"]),
|
||||
"store": str(CURRENT)}))
|
||||
return 0
|
||||
|
||||
doc = _fresh_doc()
|
||||
|
||||
if cmd == "entity":
|
||||
e = entity(doc, args[0]) if args else None
|
||||
print(json.dumps(e) if e else json.dumps({"error": "not found", "id": args[0] if args else ""}))
|
||||
return 0 if e else 1
|
||||
if cmd == "relationships":
|
||||
out = relationships(doc, from_id=args[0] if args else None,
|
||||
type=args[1] if len(args) > 1 else None)
|
||||
print(json.dumps(out, indent=1))
|
||||
return 0
|
||||
if cmd == "owner":
|
||||
e = entity(doc, args[0]) if args else None
|
||||
if not e:
|
||||
print(json.dumps({"error": "not found"})); return 1
|
||||
subs = [r["to"] for r in relationships(doc, from_id=e["id"], type="belongs_to")]
|
||||
print(json.dumps({"id": e["id"], "subsystem": subs}))
|
||||
return 0
|
||||
if cmd == "dependencies":
|
||||
out = [r["to"] for r in relationships(doc, from_id=args[0], type="depends_on")] if args else []
|
||||
print(json.dumps({"id": args[0] if args else "", "dependencies": sorted(out)}))
|
||||
return 0
|
||||
if cmd == "consumers":
|
||||
# "consumed_by" edges run provider → consumer; consumers of X are
|
||||
# the "to" ends of edges leaving X.
|
||||
out = [r["to"] for r in relationships(doc, from_id=args[0], type="consumed_by")] if args else []
|
||||
print(json.dumps({"id": args[0] if args else "", "consumers": sorted(set(out))}))
|
||||
return 0
|
||||
if cmd == "status":
|
||||
e = entity(doc, args[0]) if args else None
|
||||
if not e:
|
||||
print(json.dumps({"error": "not found"})); return 1
|
||||
print(json.dumps({"id": e["id"], "kind": e["kind"], "maturity": e["maturity"],
|
||||
"evidence_refs": e["evidence_refs"], "observed_at": e["observed_at"]}))
|
||||
return 0
|
||||
if cmd == "drift":
|
||||
print(json.dumps(drift(doc), indent=1))
|
||||
return 0
|
||||
if cmd == "deployed":
|
||||
obs = _deployed_observation()
|
||||
print(json.dumps(obs if obs else {"error": "no deployment observation"}))
|
||||
return 0 if obs else 1
|
||||
if cmd == "snapshot":
|
||||
p = snapshot(doc, args[0] if args else "")
|
||||
print(json.dumps({"snapshot": str(p)}))
|
||||
return 0
|
||||
if cmd == "diff":
|
||||
a, b = load(Path(args[0])), load(Path(args[1]))
|
||||
if not a or not b:
|
||||
print(json.dumps({"error": "snapshot path not found"})); return 1
|
||||
print(json.dumps(diff(a, b), indent=1))
|
||||
return 0
|
||||
if cmd == "export-umbrellas":
|
||||
view = export_umbrellas(doc)
|
||||
target = Path(args[0]) if args else UMBRELLA_MANIFEST
|
||||
if target == UMBRELLA_MANIFEST and target.is_file() and GENERATED_VIEW_MARKER not in json.loads(target.read_text()):
|
||||
print(json.dumps({"error": "refusing to overwrite a MANUAL manifest — "
|
||||
"pass an explicit output path for the generated view"}))
|
||||
return 1
|
||||
target.write_text(json.dumps(view, indent=1) + "\n")
|
||||
print(json.dumps({"written": str(target), "subsystems": len(view["umbrellas"])}))
|
||||
return 0
|
||||
if cmd == "query":
|
||||
print(json.dumps(query(doc, args[0] if args else "")))
|
||||
return 0
|
||||
if cmd == "impact-frontier":
|
||||
seeds = args if args else []
|
||||
if len(seeds) == 1 and seeds[0].startswith("task:"):
|
||||
seeds = task_entities(doc, seeds[0][5:]) or []
|
||||
print(json.dumps(impact_frontier(doc, seeds), indent=1))
|
||||
return 0
|
||||
if cmd == "uri":
|
||||
print(json.dumps({"uri": spine_uri(args[0] if args else "")}))
|
||||
return 0
|
||||
if cmd == "authority-health":
|
||||
print(json.dumps(authority_health(doc), indent=1))
|
||||
return 0
|
||||
if cmd == "alias":
|
||||
# alias <canonical> <old-id> — rename support: old spine:// refs stay resolvable
|
||||
if len(args) < 2:
|
||||
print(json.dumps({"error": "alias <canonical_id> <old_id>"})); return 2
|
||||
e = alias(doc, args[0], args[1])
|
||||
save(doc)
|
||||
print(json.dumps(e or {"error": "not found"}))
|
||||
return 0 if e else 1
|
||||
if cmd == "supersede":
|
||||
# supersede <new> <old> <evidence> — replacement with preserved history
|
||||
if len(args) < 3:
|
||||
print(json.dumps({"error": "supersede <new_id> <old_id> <evidence_ref>"})); return 2
|
||||
old = supersede(doc, args[0], args[1], args[2])
|
||||
save(doc)
|
||||
print(json.dumps(old or {"error": "not found"}))
|
||||
return 0 if old else 1
|
||||
if cmd == "promote":
|
||||
e = promote(doc, args[0], args[1], args[2]) if len(args) > 2 else None
|
||||
save(doc)
|
||||
print(json.dumps(e or {"error": "not found or missing evidence"}))
|
||||
return 0 if e else 1
|
||||
if cmd == "maturity":
|
||||
print(json.dumps(MATURITY_ORDER))
|
||||
return 0
|
||||
|
||||
print(f"unknown command: {cmd}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,281 @@
|
||||
"""stage_guards.py — T15 §15F–§15I: guarded stage execution for Scaffolding.
|
||||
|
||||
Design lessons absorbed (NOT the systems they came from): pre/post-
|
||||
conditions and invariants (Design by Contract), readiness gating of
|
||||
downstream work (DAGs), distinct startup/liveness/exit probe meanings
|
||||
(K8s probes), desired-vs-actual reconciliation (K8s controllers),
|
||||
idempotent retry + failure classification (Temporal), explicit
|
||||
inputs/outputs and null-build checks (Bazel), policy decision separated
|
||||
from the governed actor (policy-as-code).
|
||||
|
||||
§15F THREE CANARY MEANINGS — deliberately NOT one generic flag:
|
||||
|
||||
READINESS — may this stage begin? failure BLOCKS the stage
|
||||
LIVENESS — is progress still possible? inactivity ≠ completion
|
||||
EXIT — did reality reach the expected state, invariants held?
|
||||
a stage cannot become PASS without it
|
||||
|
||||
§15F INDEPENDENT COMPLETION AUTHORITY (14X.8 continued): the actor that
|
||||
performed the stage action may NOT self-certify its exit canary. The
|
||||
canary executes the check itself (command, readback, assertion) and
|
||||
records evidence; actor claims are evidence INPUT only.
|
||||
|
||||
§15G TEST TRUTH: exit code 0 is not proof. Where the contract requires
|
||||
tests, the exit canary requires: non-zero executed test count, no
|
||||
skip-all, expected artifact present. Expectations come from the
|
||||
contract, never hard-coded.
|
||||
|
||||
§15H HENXELS PLACEMENT: structural/system-health guard at proportionate
|
||||
boundaries — REQUIRED for structural integration boundaries and
|
||||
FULL/PROJECT_BUILD closure, NOT_APPLICABLE for tiny bounded changes.
|
||||
Every material boundary records which, with rationale.
|
||||
|
||||
§15I DESIRED VS ACTUAL: after consequential actions — persist, reload
|
||||
canonical reality, compare against desired. A command exiting 0 is not
|
||||
an effect. Zero/acceptable delta gates the NEXT stage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── §15F guarded stage contract ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class StageCheck:
|
||||
"""One named, executable readiness/exit check. The CHECK executes —
|
||||
it is never satisfied by a claim."""
|
||||
name: str
|
||||
kind: str # "command" | "path_exists" | "assert"
|
||||
# command: argv list; truth from exit code (+ optional output_pattern)
|
||||
argv: list | None = None
|
||||
output_pattern: str = "" # regex that must appear on stdout (15G)
|
||||
# path_exists: path that must exist after the stage
|
||||
path: str = ""
|
||||
# assert: callable(state) -> bool (in-process deterministic check)
|
||||
check: Any = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageContract:
|
||||
"""Minimum guarded-stage semantics expressed over existing
|
||||
construction state. Not a workflow engine: a stage contract is data
|
||||
attached to (or derived from) a Construction Contract stage."""
|
||||
stage_id: str
|
||||
contract_id: str = ""
|
||||
dependencies: list = field(default_factory=list) # stage_ids
|
||||
ready_when: list = field(default_factory=list) # [StageCheck]
|
||||
done_when: list = field(default_factory=list) # [StageCheck]
|
||||
invariants: list = field(default_factory=list) # [StageCheck]
|
||||
expected_effect: str = ""
|
||||
retry_class: str = "UNKNOWN" # TRANSIENT/INTERMITTENT/KNOWN_PROCESS_
|
||||
# DEFECT/PERMANENT/APPROVAL_REQUIRED/UNKNOWN
|
||||
idempotency_key: str = "" # operation identity for null-action
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
def chk(c: StageCheck) -> dict:
|
||||
return {"name": c.name, "kind": c.kind, "argv": c.argv,
|
||||
"output_pattern": c.output_pattern, "path": c.path,
|
||||
"description": c.description}
|
||||
return {"stage_id": self.stage_id, "contract_id": self.contract_id,
|
||||
"dependencies": list(self.dependencies),
|
||||
"ready_when": [chk(c) for c in self.ready_when],
|
||||
"done_when": [chk(c) for c in self.done_when],
|
||||
"invariants": [chk(c) for c in self.invariants],
|
||||
"expected_effect": self.expected_effect,
|
||||
"retry_class": self.retry_class,
|
||||
"idempotency_key": self.idempotency_key}
|
||||
|
||||
|
||||
class StageBlocked(Exception):
|
||||
def __init__(self, stage_id: str, failures: list[dict]):
|
||||
self.stage_id = stage_id
|
||||
self.failures = failures
|
||||
super().__init__(f"stage {stage_id} blocked: "
|
||||
f"{[f['name'] for f in failures]}")
|
||||
|
||||
|
||||
class ExitCanaryFailed(Exception):
|
||||
def __init__(self, stage_id: str, failures: list[dict]):
|
||||
self.stage_id = stage_id
|
||||
self.failures = failures
|
||||
super().__init__(f"stage {stage_id} exit canary FAILED: "
|
||||
f"{[f['name'] for f in failures]}")
|
||||
|
||||
|
||||
def _run_check(check: StageCheck) -> dict:
|
||||
"""Execute ONE check and record evidence. Returns
|
||||
{name, passed, evidence}. A check that cannot execute is a FAILURE
|
||||
(cannot-execute is never a pass)."""
|
||||
ev: dict = {"name": check.name, "kind": check.kind}
|
||||
try:
|
||||
if check.kind == "command":
|
||||
r = subprocess.run(check.argv or [], capture_output=True,
|
||||
text=True, timeout=300)
|
||||
ev["exit_code"] = r.returncode
|
||||
passed = r.returncode == 0
|
||||
if passed and check.output_pattern:
|
||||
m = re.search(check.output_pattern, r.stdout or "")
|
||||
ev["pattern"] = check.output_pattern
|
||||
ev["matched"] = bool(m)
|
||||
passed = bool(m)
|
||||
if m:
|
||||
ev["matched_text"] = m.group(0)[:100]
|
||||
elif check.kind == "path_exists":
|
||||
p = Path(check.path)
|
||||
ev["path"] = check.path
|
||||
passed = p.exists() and p.stat().st_size >= 0
|
||||
ev["size"] = p.stat().st_size if p.exists() else 0
|
||||
elif check.kind == "assert":
|
||||
ev["assert"] = check.description or check.name
|
||||
passed = bool(check.check({}))
|
||||
else:
|
||||
ev["error"] = f"unknown check kind {check.kind}"
|
||||
passed = False
|
||||
except Exception as e: # noqa: BLE001 — cannot-execute is a failure
|
||||
ev["error"] = str(e)[:200]
|
||||
passed = False
|
||||
ev["passed"] = passed
|
||||
return ev
|
||||
|
||||
|
||||
def readiness_canary(stage: StageContract) -> dict:
|
||||
"""§15F: may this stage begin? ALL ready_when checks must pass.
|
||||
Failure BLOCKS the stage — it does NOT auto-repair."""
|
||||
failures = [ev for ev in (_run_check(c) for c in stage.ready_when)
|
||||
if not ev["passed"]]
|
||||
verdict = "READY" if not failures else "BLOCKED"
|
||||
return {"stage_id": stage.stage_id, "canary": "readiness",
|
||||
"verdict": verdict,
|
||||
"checks": len(stage.ready_when), "failures": failures}
|
||||
|
||||
|
||||
def execute_stage(stage: StageContract, action) -> dict:
|
||||
"""Run one guarded stage: readiness → action → exit canary.
|
||||
The action is the caller's function; its return value is evidence
|
||||
INPUT. The exit canary independently verifies done_when + invariants.
|
||||
Readiness failure raises StageBlocked (stage does not execute);
|
||||
exit failure raises ExitCanaryFailed (stage cannot become PASS)."""
|
||||
ready = readiness_canary(stage)
|
||||
if ready["verdict"] == "BLOCKED":
|
||||
raise StageBlocked(stage.stage_id, ready["failures"])
|
||||
action_evidence = action() if action else {}
|
||||
exit_evidence = [_run_check(c) for c in stage.done_when]
|
||||
invariant_evidence = [_run_check(c) for c in stage.invariants]
|
||||
failures = [e for e in exit_evidence + invariant_evidence
|
||||
if not e["passed"]]
|
||||
result = {"stage_id": stage.stage_id, "canary": "exit",
|
||||
"verdict": "PASS" if not failures else "FAILED",
|
||||
"action_evidence": action_evidence,
|
||||
"exit_checks": exit_evidence,
|
||||
"invariant_checks": invariant_evidence,
|
||||
"failures": failures}
|
||||
if failures:
|
||||
# §15F: the stage cannot become PASS — and "cannot" means the
|
||||
# caller gets an exception, not a verdict it may ignore.
|
||||
raise ExitCanaryFailed(stage.stage_id, failures)
|
||||
return result
|
||||
|
||||
|
||||
def liveness_canary(last_progress_at: float, now: float | None = None,
|
||||
stall_limit_secs: float = 300.0) -> dict:
|
||||
"""§15F: is progress still possible? Inactivity NEVER means
|
||||
completion — a stalled stage is STALLED, not done."""
|
||||
now = now if now is not None else time.monotonic()
|
||||
stalled = (now - last_progress_at) > stall_limit_secs
|
||||
return {"canary": "liveness", "stalled": stalled,
|
||||
"idle_secs": round(now - last_progress_at, 1),
|
||||
"verdict": "STALLED" if stalled else "ALIVE"}
|
||||
|
||||
|
||||
# ── §15G verify test truth, not just exit code ──────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class TestExpectations:
|
||||
"""Contract-derived verification expectations (never universal
|
||||
constants)."""
|
||||
require_tests: bool = True
|
||||
min_tests: int = 1
|
||||
test_output_pattern: str = r"(?:# pass (\d+)|(\d+) passed)"
|
||||
forbid_all_skipped: str = r"# skipped (\d+)"
|
||||
required_artifacts: list = field(default_factory=list) # paths
|
||||
|
||||
|
||||
def verify_test_truth(cmd: list, cwd: str, expectations: TestExpectations,
|
||||
*, base_dir: str = ".") -> dict:
|
||||
"""§15G: run the verification command and judge TRUTH, not exit code.
|
||||
False-green hostile cases rejected: zero tests discovered with exit 0,
|
||||
all tests skipped, expected artifact absent."""
|
||||
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,
|
||||
timeout=300)
|
||||
evidence: dict = {"cmd": cmd, "exit_code": r.returncode,
|
||||
"stdout_tail": (r.stdout or "")[-500:]}
|
||||
failures: list[dict] = []
|
||||
if r.returncode != 0:
|
||||
failures.append({"name": "exit_code", "exit_code": r.returncode})
|
||||
if expectations.require_tests:
|
||||
m = re.search(expectations.test_output_pattern, r.stdout or "")
|
||||
count = int(m.group(1) or m.group(2)) if m else 0
|
||||
evidence["test_count"] = count
|
||||
if count == 0:
|
||||
# zero tests + exit 0 is a false green when tests are required
|
||||
failures.append({"name": "test_count", "count": 0,
|
||||
"why": "zero tests executed — false green"})
|
||||
elif count < expectations.min_tests:
|
||||
failures.append({"name": "test_count", "count": count,
|
||||
"min_required": expectations.min_tests})
|
||||
if expectations.forbid_all_skipped:
|
||||
s = re.search(expectations.forbid_all_skipped, r.stdout or "")
|
||||
if s and count and int(s.group(1)) >= count:
|
||||
failures.append({"name": "all_skipped",
|
||||
"skipped": int(s.group(1))})
|
||||
for art in expectations.required_artifacts:
|
||||
p = Path(base_dir) / art
|
||||
if not p.exists() or p.stat().st_size == 0:
|
||||
failures.append({"name": "artifact_missing", "path": art})
|
||||
evidence["failures"] = failures
|
||||
evidence["verdict"] = "PASS" if not failures else "FALSE_GREEN_REFUSED"
|
||||
return evidence
|
||||
|
||||
|
||||
# ── §15H Henxels as proportional structural guard ───────────────────────────
|
||||
|
||||
def henxels_placement(*, depth: str, structural_boundary: bool,
|
||||
stage_id: str) -> dict:
|
||||
"""§15H: is Henxels REQUIRED at this boundary? Proportional, not
|
||||
ceremonial. Records the decision with rationale (either way)."""
|
||||
if structural_boundary or depth in ("FULL", "PROJECT_BUILD"):
|
||||
return {"stage_id": stage_id, "henxels": "HENXELS_REQUIRED",
|
||||
"rationale": (f"depth={depth}, structural_boundary="
|
||||
f"{structural_boundary} — structural "
|
||||
f"integration evidence required")}
|
||||
return {"stage_id": stage_id, "henxels": "HENXELS_NOT_APPLICABLE",
|
||||
"rationale": (f"depth={depth}, no structural boundary — "
|
||||
f"focused exit canary suffices")}
|
||||
|
||||
|
||||
# ── §15I desired vs actual reconciliation ───────────────────────────────────
|
||||
|
||||
def reconcile(desired: dict, read_actual, *, stage_id: str = "",
|
||||
tolerance: set | None = None) -> dict:
|
||||
"""§15I: persist → reload canonical reality → compare. `read_actual`
|
||||
is a callable returning the ACTUAL canonical state (fresh read, never
|
||||
a cached copy). Returns the delta; empty delta = continue. The next
|
||||
stage MUST NOT start on a non-empty delta."""
|
||||
tolerance = tolerance or set()
|
||||
actual = read_actual()
|
||||
delta: dict = {}
|
||||
for key, want in desired.items():
|
||||
if key in tolerance:
|
||||
continue
|
||||
got = actual.get(key) if isinstance(actual, dict) else None
|
||||
if got != want:
|
||||
delta[key] = {"desired": want, "actual": got}
|
||||
return {"stage_id": stage_id, "reconciled": not delta, "delta": delta}
|
||||
@@ -0,0 +1,650 @@
|
||||
"""structural_store.py — shared generic structural-store primitive.
|
||||
|
||||
The durable-value engine of Rune's structural self-models, extracted from
|
||||
spine.py (2026-09-13, SCAFFOLD-TRANCHE-001) so that Project Scaffolds can
|
||||
use the identical open-world store, evidence-gated maturity ladder,
|
||||
authority-owned reconciliation, alias/supersession, impact-frontier and
|
||||
snapshot/diff machinery WITHOUT importing Rune-specific assumptions.
|
||||
|
||||
This module contains NO Rune-specific constants: no repository paths, no
|
||||
src/rune scanning, no test-dir scanning, no deployed-link inspection, no
|
||||
Umbrella manifest, no Rune entity vocabulary. Rune-specific projectors and
|
||||
paths remain in spine.py; project-specific projectors live in scaffold.py.
|
||||
|
||||
Behavior note: every function here is moved verbatim from spine.py (or
|
||||
parameterized only by explicitly-passed paths). Spine V1's behavior is
|
||||
unchanged; spine.py re-exports these names for compatibility.
|
||||
|
||||
Store-path contract: load/save take an explicit `path`. Callers that want
|
||||
"the store" pass their own default (spine passes its CURRENT; scaffold
|
||||
passes the project's). Nothing here resolves a path from the environment.
|
||||
|
||||
Entity/relationship vocabulary is OPEN: kinds, relationship types and
|
||||
metadata are data, never validated against a whitelist. Unknown future
|
||||
kinds/fields round-trip by construction.
|
||||
|
||||
Schema version: 1 (shared with spine stores).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
MATURITY_ORDER = ["DECLARED", "PRESENT", "WIRED", "REACHABLE", "EXERCISED", "LIVE_PROVEN"]
|
||||
MATURITY_RANK = {name: i for i, name in enumerate(MATURITY_ORDER)}
|
||||
|
||||
REL_STATUS = {"UNKNOWN", "CURRENT", "STALE", "CONFLICTING", "DEGRADED"}
|
||||
|
||||
# Relationship classes that must never expand an impact frontier
|
||||
# (data-driven semantics table; unknown types default to obligation-bearing).
|
||||
NON_OBLIGATION_RELATIONSHIPS = {
|
||||
"documented_by",
|
||||
"historical_relation",
|
||||
"related_memory",
|
||||
"supersedes", # supersession is context, not a live obligation
|
||||
}
|
||||
FRONTIER_LEVEL1_OUT = {"consumed_by", "depends_on", "implements", "consumes", "belongs_to", "deployed_as"}
|
||||
FRONTIER_CONTRACT_EXPANSION = {"implements", "consumes"}
|
||||
FRONTIER_LEVEL2_TYPES = {"verified_by", "obligated_canary"}
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _sorted(obj):
|
||||
"""Deterministic normalization: sort everything sortable, recursively."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sorted(obj[k]) for k in sorted(obj)}
|
||||
if isinstance(obj, list):
|
||||
return sorted((_sorted(v) for v in obj), key=lambda v: json.dumps(v, sort_keys=True))
|
||||
return obj
|
||||
|
||||
|
||||
def new_doc() -> dict:
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"generated_at": now(),
|
||||
"entities": [],
|
||||
"relationships": [],
|
||||
"observations": [],
|
||||
}
|
||||
|
||||
|
||||
# ── store I/O (path-explicit; no environment resolution) ─────────────────
|
||||
|
||||
|
||||
def load(path: Path) -> dict | None:
|
||||
if not Path(path).is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(Path(path).read_text())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
_SECRET_KEY_NAMES = {
|
||||
"token",
|
||||
"api_token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"password",
|
||||
"secret",
|
||||
"auth_header",
|
||||
"authorization",
|
||||
"credential",
|
||||
"credentials",
|
||||
"private_key",
|
||||
"access_token",
|
||||
"bearer",
|
||||
}
|
||||
|
||||
|
||||
def _redact_secrets(obj):
|
||||
"""RECURSIVE SECRET REDACTION (defense in depth for persistence): any
|
||||
dict key named like a credential has its value stripped before
|
||||
serialization. Structural stores must never become secret stores."""
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: ("<redacted>" if str(k).lower() in _SECRET_KEY_NAMES else _redact_secrets(v)) for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_redact_secrets(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def save(doc: dict, path: Path | str) -> Path:
|
||||
incoming = doc.get("schema_version", SCHEMA_VERSION)
|
||||
if incoming != SCHEMA_VERSION:
|
||||
raise ValueError(f"schema_version {incoming} != {SCHEMA_VERSION}")
|
||||
doc["schema_version"] = SCHEMA_VERSION
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(_sorted(_redact_secrets(doc)), indent=1, sort_keys=True) + "\n")
|
||||
return p
|
||||
|
||||
|
||||
def canonical_digest(doc: dict) -> str:
|
||||
"""SCAFFOLD-TRANCHE-0012 §12B: stable digest of a document's SEMANTIC
|
||||
content — the same normalization save() uses on disk (_sorted +
|
||||
_redact_secrets + sort_keys). Key ordering cannot create false failure;
|
||||
identical semantic content always yields an identical digest."""
|
||||
import hashlib
|
||||
|
||||
payload = json.dumps(_sorted(_redact_secrets(doc)), sort_keys=True)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def snapshot(doc: dict, snapshots_dir: Path, label: str = "", tag: str = "") -> Path:
|
||||
"""Persist an immutable snapshot. `tag` is a caller-supplied identity
|
||||
fragment (Spine uses the deployed SHA; a Project Scaffold may use its
|
||||
repo HEAD or 'noident')."""
|
||||
snapshots_dir = Path(snapshots_dir)
|
||||
snapshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = f"snap-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}-{(tag or 'noident')[:8]}"
|
||||
if label:
|
||||
name += f"-{label}"
|
||||
p = snapshots_dir / f"{name}.json"
|
||||
save(doc, p)
|
||||
return p
|
||||
|
||||
|
||||
# ── entities & relationships (open world) ─────────────────────────────────
|
||||
|
||||
|
||||
def add_entity(
|
||||
doc: dict, *, id: str, kind: str, tags=None, identities=None, evidence_refs=None, maturity="DECLARED", ext=None
|
||||
) -> dict:
|
||||
"""Register an entity. kind/tags/ext are open — unknown values are data.
|
||||
|
||||
Never downgrades an existing entity's maturity (high-water upgrade):
|
||||
re-derivation with fresher but lower evidence keeps the earned state."""
|
||||
for e in doc["entities"]:
|
||||
if e["id"] == id:
|
||||
if maturity and MATURITY_RANK.get(maturity, 0) > MATURITY_RANK.get(e.get("maturity", "DECLARED"), 0):
|
||||
e["maturity"] = maturity
|
||||
if tags:
|
||||
e["tags"] = sorted(set(e.get("tags", [])) | set(tags))
|
||||
if identities:
|
||||
e.setdefault("identities", {}).update(identities)
|
||||
if evidence_refs:
|
||||
e["evidence_refs"] = sorted(set(e.get("evidence_refs", [])) | set(evidence_refs))
|
||||
if ext:
|
||||
e.setdefault("ext", {}).update(ext)
|
||||
e["observed_at"] = now()
|
||||
return e
|
||||
ent = {
|
||||
"id": id,
|
||||
"kind": kind,
|
||||
"maturity": maturity,
|
||||
"tags": sorted(tags or []),
|
||||
"identities": dict(identities or {}),
|
||||
"evidence_refs": sorted(evidence_refs or []),
|
||||
"observed_at": now(),
|
||||
}
|
||||
if ext:
|
||||
ent["ext"] = dict(ext)
|
||||
doc["entities"].append(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def add_relationship(doc: dict, *, type: str, from_id: str, to_id: str, evidence_refs=None, ext=None) -> dict:
|
||||
rel = {
|
||||
"type": type,
|
||||
"from": from_id,
|
||||
"to": to_id,
|
||||
"evidence_refs": sorted(evidence_refs or []),
|
||||
"observed_at": now(),
|
||||
}
|
||||
if ext:
|
||||
rel["ext"] = dict(ext)
|
||||
if not any(r["type"] == type and r["from"] == from_id and r["to"] == to_id for r in doc["relationships"]):
|
||||
# MODEL SPECULATION GUARD: a relationship proposed without evidence
|
||||
# is CANDIDATE — representable but never structural truth (excluded
|
||||
# from frontiers until evidence promotes it).
|
||||
if not rel["evidence_refs"] and "status" not in rel:
|
||||
rel["status"] = "CANDIDATE"
|
||||
doc["relationships"].append(rel)
|
||||
return rel
|
||||
|
||||
|
||||
def entity(doc: dict, id: str) -> dict | None:
|
||||
"""Resolve an entity id; follows alias entries so old references stay
|
||||
meaningful after rename (rename-resilient resolution)."""
|
||||
doc_ents = doc.get("entities", [])
|
||||
for e in doc_ents:
|
||||
if e["id"] == id:
|
||||
return e
|
||||
for e in doc_ents:
|
||||
if id in e.get("aliases", []):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def relationships(doc: dict, *, from_id=None, to_id=None, type=None) -> list[dict]:
|
||||
out = []
|
||||
for r in doc.get("relationships", []):
|
||||
if from_id and r["from"] != from_id:
|
||||
continue
|
||||
if to_id and r["to"] != to_id:
|
||||
continue
|
||||
if type and r["type"] != type:
|
||||
continue
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def promote(doc: dict, id: str, state: str, evidence_ref: str) -> dict | None:
|
||||
"""Evidence-gated maturity promotion. No evidence ref = no promotion.
|
||||
Demotion is never silent: recorded, not applied."""
|
||||
e = entity(doc, id)
|
||||
if e is None:
|
||||
return None
|
||||
if not evidence_ref:
|
||||
raise ValueError(f"promotion of {id} to {state} requires evidence")
|
||||
if state not in MATURITY_RANK:
|
||||
raise ValueError(f"unknown maturity state: {state}")
|
||||
current = MATURITY_RANK.get(e.get("maturity", "DECLARED"), 0)
|
||||
if MATURITY_RANK[state] < current:
|
||||
doc["observations"].append(
|
||||
{
|
||||
"kind": "maturity_demotion_rejected",
|
||||
"entity": id,
|
||||
"requested": state,
|
||||
"current": e["maturity"],
|
||||
"observed_at": now(),
|
||||
}
|
||||
)
|
||||
return e
|
||||
e["maturity"] = state
|
||||
e["evidence_refs"] = sorted(set(e.get("evidence_refs", [])) | {evidence_ref})
|
||||
e["observed_at"] = now()
|
||||
return e
|
||||
|
||||
|
||||
# ── durable identity: rename / supersession ───────────────────────────────
|
||||
|
||||
|
||||
def alias(doc: dict, canonical_id: str, old_id: str) -> dict:
|
||||
"""RENAME: same conceptual entity, identifier changed. Old references
|
||||
stay resolvable (entity() follows alias entries)."""
|
||||
e = entity(doc, canonical_id)
|
||||
if e is None:
|
||||
raise ValueError(f"alias target not found: {canonical_id}")
|
||||
if canonical_id == old_id:
|
||||
raise ValueError("self-alias")
|
||||
aliases = e.setdefault("aliases", [])
|
||||
if old_id not in aliases:
|
||||
aliases.append(old_id)
|
||||
e["observed_at"] = now()
|
||||
return e
|
||||
|
||||
|
||||
def supersede(doc: dict, new_id: str, old_id: str, evidence_ref: str) -> dict:
|
||||
"""REPLACEMENT: new entity supersedes old; old identity remains
|
||||
resolvable historically. Evidence required."""
|
||||
if not evidence_ref:
|
||||
raise ValueError("supersession requires evidence")
|
||||
new_e, old_e = entity(doc, new_id), entity(doc, old_id)
|
||||
if new_e is None or old_e is None:
|
||||
raise ValueError("supersede requires both entities to exist")
|
||||
add_relationship(doc, type="supersedes", from_id=new_id, to_id=old_id, evidence_refs=[evidence_ref])
|
||||
old_e["superseded_by"] = new_id
|
||||
old_e["status"] = "SUPERSEDED"
|
||||
doc["observations"].append(
|
||||
{"kind": "superseded", "old": old_id, "new": new_id, "evidence": evidence_ref, "observed_at": now()}
|
||||
)
|
||||
return old_e
|
||||
|
||||
|
||||
def spine_uri(entity_id: str) -> str:
|
||||
"""Stable cross-graph identity: spine://<kind>/<name>. A future Memory
|
||||
record cites this URI without duplicating structural authority."""
|
||||
kind, _, name = entity_id.partition(":")
|
||||
return f"spine://{kind}/{name}"
|
||||
|
||||
|
||||
def resolve_uri(uri: str) -> str:
|
||||
scheme, _, rest = uri.partition("://")
|
||||
if scheme != "spine":
|
||||
raise ValueError(f"not a spine URI: {uri}")
|
||||
kind, _, name = rest.partition("/")
|
||||
return f"{kind}:{name}"
|
||||
|
||||
|
||||
# ── authority-owned reconciliation + state semantics ──────────────────────
|
||||
|
||||
|
||||
def _doc_authorities(doc: dict) -> dict:
|
||||
return doc.setdefault("authority_generations", {})
|
||||
|
||||
|
||||
def _authority_observed(doc: dict, rel: dict | None, authority: str) -> None:
|
||||
a = _doc_authorities(doc).setdefault(authority, {"generation": 0, "observed": []})
|
||||
a["generation"] += 1 if rel is None else 0
|
||||
a["last_success"] = now()
|
||||
a["status"] = "CURRENT"
|
||||
if rel is not None:
|
||||
rel["authority"] = authority
|
||||
rel["status"] = "CURRENT"
|
||||
rel["observed_at"] = now()
|
||||
oid = f"{rel['type']}|{rel['from']}|{rel['to']}"
|
||||
if oid not in a["observed"]:
|
||||
a["observed"].append(oid)
|
||||
|
||||
|
||||
def authority_failed(doc: dict, authority: str, error: str) -> None:
|
||||
"""A projector failed: its facts go STALE (not silently fresh) and the
|
||||
authority is recorded DEGRADED."""
|
||||
a = _doc_authorities(doc).setdefault(authority, {"generation": 0, "observed": []})
|
||||
a["last_attempt"] = now()
|
||||
a["last_error"] = error[:200]
|
||||
a["status"] = "DEGRADED"
|
||||
for rel in doc["relationships"]:
|
||||
if rel.get("authority") == authority and rel.get("status") == "CURRENT":
|
||||
rel["status"] = "STALE"
|
||||
doc["observations"].append(
|
||||
{"kind": "authority_degraded", "authority": authority, "error": error[:200], "observed_at": now()}
|
||||
)
|
||||
|
||||
|
||||
def reconcile_authorities(doc: dict) -> list[dict]:
|
||||
"""After projector passes: facts owned by an authority that did not
|
||||
re-observe them become STALE. Manual facts (no authority) untouched."""
|
||||
authorities = _doc_authorities(doc)
|
||||
staled = []
|
||||
for rel in doc["relationships"]:
|
||||
auth = rel.get("authority")
|
||||
if not auth or rel.get("status") == "STALE":
|
||||
continue
|
||||
oid = f"{rel['type']}|{rel['from']}|{rel['to']}"
|
||||
if oid not in authorities.get(auth, {}).get("observed", []):
|
||||
rel["status"] = "STALE"
|
||||
staled.append({"type": rel["type"], "from": rel["from"], "to": rel["to"], "authority": auth})
|
||||
return staled
|
||||
|
||||
|
||||
def mark_conflict(
|
||||
doc: dict, entity_id: str, authority_a: str, claim_a: str, authority_b: str, claim_b: str, evidence_ref: str
|
||||
) -> dict:
|
||||
"""Two authoritative observations disagree: BOTH preserved, entity
|
||||
flagged CONFLICTING — never last-wins."""
|
||||
e = entity(doc, entity_id)
|
||||
if e is None:
|
||||
raise ValueError(f"conflict on unknown entity: {entity_id}")
|
||||
if not evidence_ref:
|
||||
raise ValueError("conflict recording requires evidence")
|
||||
conflicts = e.setdefault("ext", {}).setdefault("conflicts", [])
|
||||
conflicts.append(
|
||||
{
|
||||
"authority_a": authority_a,
|
||||
"claim_a": claim_a,
|
||||
"authority_b": authority_b,
|
||||
"claim_b": claim_b,
|
||||
"evidence": evidence_ref,
|
||||
"observed_at": now(),
|
||||
}
|
||||
)
|
||||
e["status"] = "CONFLICTING"
|
||||
doc["observations"].append(
|
||||
{
|
||||
"kind": "authority_conflict",
|
||||
"entity": entity_id,
|
||||
"authority_a": authority_a,
|
||||
"claim_a": claim_a,
|
||||
"authority_b": authority_b,
|
||||
"claim_b": claim_b,
|
||||
"observed_at": now(),
|
||||
}
|
||||
)
|
||||
return e
|
||||
|
||||
|
||||
def resolve_status(e: dict) -> str:
|
||||
"""Smallest coherent entity state. 'Not checked' is UNKNOWN, never
|
||||
healthy."""
|
||||
if e.get("status"):
|
||||
return e["status"]
|
||||
if e.get("ext", {}).get("conflicts"):
|
||||
return "CONFLICTING"
|
||||
if e.get("maturity") in ("EXERCISED", "LIVE_PROVEN") and e.get("evidence_refs"):
|
||||
return "CURRENT"
|
||||
if e.get("maturity") not in (None, "DECLARED") and not e.get("evidence_refs"):
|
||||
return "CONFLICTING" # claim without proof
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def authority_health(doc: dict) -> dict:
|
||||
"""Provenance/freshness per projector — provenance only, not monitoring."""
|
||||
return {
|
||||
name: {
|
||||
"generation": a.get("generation", 0),
|
||||
"status": a.get("status", "UNKNOWN"),
|
||||
"last_success": a.get("last_success"),
|
||||
"last_attempt": a.get("last_attempt"),
|
||||
"last_error": a.get("last_error"),
|
||||
"facts_observed": len(a.get("observed", [])),
|
||||
}
|
||||
for name, a in sorted(_doc_authorities(doc).items())
|
||||
}
|
||||
|
||||
|
||||
# ── task binding + structural impact resolution ───────────────────────────
|
||||
|
||||
|
||||
def bind_task(doc: dict, task_id: str, entity_ids: list[str], evidence_ref: str = "") -> dict:
|
||||
"""TASK/ISSUE/INTENT BINDING: task → affects → entities. Deterministic."""
|
||||
for eid in entity_ids:
|
||||
add_relationship(
|
||||
doc,
|
||||
type="affects",
|
||||
from_id=f"task:{task_id}",
|
||||
to_id=eid,
|
||||
evidence_refs=[evidence_ref or f"task-binding:{task_id}"],
|
||||
)
|
||||
return entity(doc, f"task:{task_id}") or {"id": f"task:{task_id}"}
|
||||
|
||||
|
||||
def task_entities(doc: dict, task_id: str) -> list[str]:
|
||||
return sorted(r["to"] for r in relationships(doc, from_id=f"task:{task_id}", type="affects"))
|
||||
|
||||
|
||||
def impact_frontier(doc: dict, seed_ids: list[str], max_level: int = 2, change_kind: str | None = None) -> dict:
|
||||
"""STRUCTURAL IMPACT RESOLUTION — bounded, deterministic, Spine-edges-only.
|
||||
|
||||
change_kind semantics: contract_change (seed is a contract → all parties
|
||||
become direct obligations); implementation_change (contract preserved →
|
||||
the contract is the obligation, sibling implementers become contextual);
|
||||
contract_violation (escalates siblings to obligations). None infers from
|
||||
seed kind. Unknown relationship types are obligation-bearing (fail-loud);
|
||||
NON_OBLIGATION types and CANDIDATE/STALE facts are skipped."""
|
||||
seeds = list(dict.fromkeys(seed_ids))
|
||||
level0 = sorted(set(seeds))
|
||||
level1: set[str] = set()
|
||||
contextual: set[str] = set()
|
||||
contracts: set[str] = set()
|
||||
|
||||
if change_kind is None:
|
||||
change_kind = (
|
||||
"contract_change"
|
||||
if any((entity(doc, s) or {}).get("kind") == "contract" for s in level0)
|
||||
else "implementation_change"
|
||||
)
|
||||
|
||||
frontier = set(level0)
|
||||
for rel in doc["relationships"]:
|
||||
if rel["type"] in NON_OBLIGATION_RELATIONSHIPS:
|
||||
continue
|
||||
if rel.get("status") in ("STALE", "CANDIDATE"):
|
||||
continue
|
||||
f, t = rel["from"], rel["to"]
|
||||
if f in frontier:
|
||||
level1.add(t)
|
||||
if t in frontier and rel["type"] == "consumed_by":
|
||||
level1.add(f)
|
||||
|
||||
contract_parties: dict[str, set[str]] = {}
|
||||
seed_contracts = {s for s in level0 if (entity(doc, s) or {}).get("kind") == "contract"}
|
||||
for rel in doc["relationships"]:
|
||||
if rel.get("status") in ("STALE", "CANDIDATE"):
|
||||
continue
|
||||
if rel["type"] in FRONTIER_CONTRACT_EXPANSION:
|
||||
a, b = rel["from"], rel["to"]
|
||||
c = b if (entity(doc, b) or {}).get("kind") == "contract" else a
|
||||
party = a if c == b else b
|
||||
contract_parties.setdefault(c, set()).add(party)
|
||||
if rel["type"] == "implements" and rel["from"] in frontier:
|
||||
seed_contracts.add(rel["to"])
|
||||
if rel["type"] == "consumes" and rel["from"] in frontier:
|
||||
seed_contracts.add(rel["to"])
|
||||
seed_contracts.update(c for c in level1 if (entity(doc, c) or {}).get("kind") == "contract")
|
||||
|
||||
contracts |= seed_contracts
|
||||
if change_kind in ("contract_change", "contract_violation"):
|
||||
for c in seed_contracts:
|
||||
level1 |= contract_parties.get(c, set())
|
||||
else:
|
||||
for c in seed_contracts:
|
||||
contextual |= contract_parties.get(c, set())
|
||||
contextual -= set(level0) | level1
|
||||
|
||||
level1 -= set(level0)
|
||||
level2: set[str] = set()
|
||||
for eid in level1 | set(level0) | contextual:
|
||||
for rel in doc["relationships"]:
|
||||
if rel.get("status") in ("STALE", "CANDIDATE"):
|
||||
continue
|
||||
if rel["type"] in FRONTIER_LEVEL2_TYPES and rel["from"] == eid:
|
||||
level2.add(rel["to"])
|
||||
if rel["type"] == "deployed_as" and rel["from"] in (level1 | set(level0)):
|
||||
level2.add(rel["to"])
|
||||
return {
|
||||
"level0_direct": level0,
|
||||
"level1_obligations": sorted(level1),
|
||||
"contracts": sorted(contracts),
|
||||
"contextual_not_obligations": sorted(contextual),
|
||||
"change_kind": change_kind,
|
||||
"level2_verification": sorted(level2),
|
||||
"traversal_scope": "spine-structural-only",
|
||||
"max_level": max_level,
|
||||
}
|
||||
|
||||
|
||||
def record_observed_impact(doc: dict, task_id: str, evidence_items: list[dict]) -> dict:
|
||||
"""INDEPENDENT OBSERVED IMPACT: derive what was ACTUALLY affected from
|
||||
external evidence (tests, canaries, runtime, deploy) — never by re-running
|
||||
the frontier being evaluated. Nothing auto-promotes into structure."""
|
||||
implicated: set[str] = set()
|
||||
for item in evidence_items:
|
||||
eid = item.get("entity", "")
|
||||
if not eid:
|
||||
continue
|
||||
implicated.add(eid)
|
||||
doc["observations"].append(
|
||||
{
|
||||
"kind": "observed_consequence",
|
||||
"task": task_id,
|
||||
"entity": eid,
|
||||
"source": item.get("source", "unattributed"),
|
||||
"detail": str(item.get("detail", ""))[:300],
|
||||
"authority": item.get("authority", ""),
|
||||
"observed_at": now(),
|
||||
}
|
||||
)
|
||||
return {"task": task_id, "observed_entities": sorted(implicated), "evidence_count": len(evidence_items)}
|
||||
|
||||
|
||||
def predicted_vs_observed(predicted: dict, observed_doc: dict, seed_ids: list[str]) -> dict:
|
||||
observed = impact_frontier(observed_doc, seed_ids)
|
||||
pred_set = {
|
||||
e
|
||||
for k in ("level0_direct", "level1_obligations", "contracts", "level2_verification")
|
||||
for e in predicted.get(k, [])
|
||||
}
|
||||
obs_set = {
|
||||
e
|
||||
for k in ("level0_direct", "level1_obligations", "contracts", "level2_verification")
|
||||
for e in observed.get(k, [])
|
||||
}
|
||||
return {
|
||||
"predicted": sorted(pred_set),
|
||||
"observed": sorted(obs_set),
|
||||
"missed": sorted(obs_set - pred_set),
|
||||
"needless": sorted(pred_set - obs_set),
|
||||
"candidate_corrections": [
|
||||
{
|
||||
"entity": e,
|
||||
"status": "CANDIDATE",
|
||||
"note": "observed consequence missing from prediction — evidence required before store update",
|
||||
}
|
||||
for e in sorted(obs_set - pred_set)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def structural_events(doc: dict) -> list[dict]:
|
||||
"""GENERIC EVENT/QUERY SEAM: dumb typed structural facts for future
|
||||
subscribers. No salience, no policy."""
|
||||
events = []
|
||||
for e in doc["entities"]:
|
||||
events.append(
|
||||
{
|
||||
"type": "entity_observed",
|
||||
"entity": e["id"],
|
||||
"kind": e["kind"],
|
||||
"maturity": e.get("maturity"),
|
||||
"observed_at": e.get("observed_at"),
|
||||
}
|
||||
)
|
||||
for r in doc["relationships"]:
|
||||
events.append(
|
||||
{
|
||||
"type": "relationship_observed",
|
||||
"relationship": r["type"],
|
||||
"from": r["from"],
|
||||
"to": r["to"],
|
||||
"observed_at": r.get("observed_at"),
|
||||
}
|
||||
)
|
||||
for o in doc.get("observations", []):
|
||||
events.append({"type": o.get("kind", "observation"), **o})
|
||||
return events
|
||||
|
||||
|
||||
def diff(old: dict, new: dict) -> dict:
|
||||
"""Deterministic structural delta. No interpretation."""
|
||||
oe = {e["id"]: e for e in old.get("entities", [])}
|
||||
ne = {e["id"]: e for e in new.get("entities", [])}
|
||||
ors = {(r["type"], r["from"], r["to"]) for r in old.get("relationships", [])}
|
||||
nrs = {(r["type"], r["from"], r["to"]) for r in new.get("relationships", [])}
|
||||
out = {
|
||||
"entities_added": sorted(set(ne) - set(oe)),
|
||||
"entities_removed": sorted(set(oe) - set(ne)),
|
||||
"relationships_added": sorted({"|".join(t) for t in nrs - ors}),
|
||||
"relationships_removed": sorted({"|".join(t) for t in ors - nrs}),
|
||||
"maturity_changed": [],
|
||||
"identity_changed": [],
|
||||
}
|
||||
for eid in sorted(set(oe) & set(ne)):
|
||||
if oe[eid].get("maturity") != ne[eid].get("maturity"):
|
||||
out["maturity_changed"].append({"id": eid, "from": oe[eid].get("maturity"), "to": ne[eid].get("maturity")})
|
||||
if oe[eid].get("identities") != ne[eid].get("identities"):
|
||||
out["identity_changed"].append(eid)
|
||||
return out
|
||||
|
||||
|
||||
def dedupe_observations(doc: dict) -> None:
|
||||
"""Exact-duplicate observation suppression (carry-forward + re-derivation
|
||||
both record; distinct repeats are history and stay)."""
|
||||
seen: set[str] = set()
|
||||
unique = []
|
||||
for o in doc.get("observations", []):
|
||||
key = json.dumps(_sorted(o), sort_keys=True)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(o)
|
||||
doc["observations"] = unique
|
||||
@@ -0,0 +1,236 @@
|
||||
"""toolchain.py — evidence-backed Toolchain Resolution + decision records.
|
||||
|
||||
SCAFFOLD-TRANCHE-003. First real resolver: infer constraints, inspect the
|
||||
existing project toolchain, produce an inspectable decision record.
|
||||
|
||||
Strong rule (tranche brief §8, §10):
|
||||
EXISTING PROJECT TOOLCHAIN WINS
|
||||
unless recorded evidence shows a significant benefit to changing it.
|
||||
A major language/runtime/framework change ALWAYS requires user approval.
|
||||
Constraint KEYS are a small fixed vocabulary (the things that can matter);
|
||||
constraint VALUES (languages, runtimes, frameworks) are open-world data —
|
||||
no language ontology lives here, and nothing is Python-default.
|
||||
|
||||
Honesty rule: constraints that are unknown produce INSUFFICIENT_EVIDENCE,
|
||||
never invented certainty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["ToolchainConstraints", "resolve_toolchain", "DecisionRecord", "CONSTRAINT_KEYS"]
|
||||
|
||||
# Small fixed set of constraint KEYS the resolver can weigh. Values are
|
||||
# open-world. Nothing here names a language.
|
||||
CONSTRAINT_KEYS = {
|
||||
"existing_language", # str: language the project already uses
|
||||
"existing_runtime", # str
|
||||
"existing_framework", # str
|
||||
"target_platform", # str: linux/macos/windows/web/embedded/...
|
||||
"deployment", # str: server/desktop/cli/container/embedded
|
||||
"performance_sensitive", # bool
|
||||
"latency_sensitive", # bool
|
||||
"memory_constrained", # bool
|
||||
"concurrency_model", # str: async/threads/processes/none
|
||||
"interoperability", # str: must embed/call into what
|
||||
"gui_native", # bool
|
||||
"expected_lifespan", # str: short/long
|
||||
"user_constraints", # list[str]: explicit user demands
|
||||
"ecosystem_requirement", # str: needed library/domain support
|
||||
}
|
||||
|
||||
_APPROVAL_KEYS = {"existing_language", "existing_runtime", "existing_framework"}
|
||||
|
||||
|
||||
class ToolchainConstraints:
|
||||
"""Open-world constraint bag. Known keys are typed; unknown keys are
|
||||
carried verbatim (future-proof without schema churn)."""
|
||||
|
||||
def __init__(self, **kw):
|
||||
self.known = {}
|
||||
self.unknown = {}
|
||||
for k, v in kw.items():
|
||||
if k in CONSTRAINT_KEYS:
|
||||
self.known[k] = v
|
||||
else:
|
||||
self.unknown[k] = v
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.known.get(key, default)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"known": dict(self.known), "unknown": dict(self.unknown)}
|
||||
|
||||
|
||||
class DecisionRecord:
|
||||
"""Durable, inspectable toolchain decision (tranche brief §9)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
selected_language: str,
|
||||
runtime: str = "",
|
||||
framework: str = "",
|
||||
libraries: list[str] | None = None,
|
||||
build_system: str = "",
|
||||
alternatives: list[dict] | None = None,
|
||||
decisive_constraints: list[str] | None = None,
|
||||
rationale: str = "",
|
||||
evidence_refs: list[str] | None = None,
|
||||
confidence: float = 0.0,
|
||||
user_approval_required: bool = False,
|
||||
resolution: str = "",
|
||||
):
|
||||
self.selected_language = selected_language
|
||||
self.runtime = runtime
|
||||
self.framework = framework
|
||||
self.libraries = sorted(libraries or [])
|
||||
self.build_system = build_system
|
||||
self.alternatives = list(alternatives or [])
|
||||
self.decisive_constraints = sorted(decisive_constraints or [])
|
||||
self.rationale = rationale
|
||||
self.evidence_refs = sorted(evidence_refs or [])
|
||||
self.confidence = confidence
|
||||
self.user_approval_required = user_approval_required
|
||||
self.resolution = resolution # KEEP_EXISTING / CHANGE_MAJOR / INSUFFICIENT_EVIDENCE
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"selected_language": self.selected_language,
|
||||
"runtime": self.runtime,
|
||||
"framework": self.framework,
|
||||
"libraries": self.libraries,
|
||||
"build_system": self.build_system,
|
||||
"alternatives": self.alternatives,
|
||||
"decisive_constraints": self.decisive_constraints,
|
||||
"rationale": self.rationale,
|
||||
"evidence_refs": self.evidence_refs,
|
||||
"confidence": self.confidence,
|
||||
"user_approval_required": self.user_approval_required,
|
||||
"resolution": self.resolution,
|
||||
}
|
||||
|
||||
|
||||
def _major_change(constraints: ToolchainConstraints, language: str) -> bool:
|
||||
"""A major toolchain change = introducing/changing a language, runtime or
|
||||
framework the project does not already use (tranche brief §10)."""
|
||||
existing = constraints.get("existing_language")
|
||||
return bool(existing) and language != existing
|
||||
|
||||
|
||||
def resolve_toolchain(
|
||||
constraints: ToolchainConstraints,
|
||||
*,
|
||||
requested_language: str | None = None,
|
||||
evidence_refs: list[str] | None = None,
|
||||
performance_evidence: dict | None = None,
|
||||
) -> DecisionRecord:
|
||||
"""Produce an evidence-backed toolchain decision.
|
||||
|
||||
Rules:
|
||||
- existing project language WINS unless significant recorded evidence
|
||||
(performance_evidence with measured basis) shows a substantial benefit
|
||||
from a different language AND the user approves.
|
||||
- requested_language differing from the project's language is a MAJOR
|
||||
change → user_approval_required=True (never silent).
|
||||
- unknown critical constraints → INSUFFICIENT_EVIDENCE, not guesses.
|
||||
- Python is NOT a default: it is selected only because the project
|
||||
already uses it or the user asked.
|
||||
"""
|
||||
refs = sorted(evidence_refs or [])
|
||||
existing = constraints.get("existing_language")
|
||||
|
||||
# INSUFFICIENT EVIDENCE path: no existing toolchain recorded AND no
|
||||
# sufficient constraints to justify any selection.
|
||||
hard_factors = [
|
||||
k
|
||||
for k in (
|
||||
"performance_sensitive",
|
||||
"latency_sensitive",
|
||||
"memory_constrained",
|
||||
"gui_native",
|
||||
"target_platform",
|
||||
"deployment",
|
||||
)
|
||||
if k in constraints.known
|
||||
]
|
||||
if not existing and not requested_language:
|
||||
if len(hard_factors) < 2:
|
||||
return DecisionRecord(
|
||||
selected_language="",
|
||||
resolution="INSUFFICIENT_EVIDENCE",
|
||||
rationale=(
|
||||
"no existing project toolchain recorded and too few "
|
||||
"constraints known to select honestly; refusing to "
|
||||
"invent certainty"
|
||||
),
|
||||
decisive_constraints=sorted(hard_factors),
|
||||
evidence_refs=refs,
|
||||
confidence=0.0,
|
||||
)
|
||||
|
||||
# DEFAULT: existing toolchain wins
|
||||
if existing and (requested_language is None or requested_language == existing):
|
||||
rationale = "existing project toolchain wins (§8); no recorded evidence shows significant benefit from change"
|
||||
decisive = ["existing_language"]
|
||||
if performance_evidence:
|
||||
decisive.append(
|
||||
"performance_evidence(rejected: does not override existing toolchain without user approval)"
|
||||
)
|
||||
return DecisionRecord(
|
||||
selected_language=existing,
|
||||
runtime=constraints.get("existing_runtime", ""),
|
||||
framework=constraints.get("existing_framework", ""),
|
||||
build_system=constraints.get("existing_build_system", ""),
|
||||
alternatives=[],
|
||||
decisive_constraints=decisive,
|
||||
rationale=rationale,
|
||||
evidence_refs=refs,
|
||||
confidence=0.85 if hard_factors else 0.7,
|
||||
user_approval_required=False,
|
||||
resolution="KEEP_EXISTING",
|
||||
)
|
||||
|
||||
# REQUESTED change away from existing: recommendable, never silent
|
||||
if existing and requested_language and requested_language != existing:
|
||||
significant = bool(performance_evidence and performance_evidence.get("significant_benefit"))
|
||||
return DecisionRecord(
|
||||
selected_language=requested_language,
|
||||
runtime=constraints.get("existing_runtime", ""),
|
||||
alternatives=[{"language": existing, "rejected_because": "requested change"}],
|
||||
decisive_constraints=["existing_language", "requested_language"]
|
||||
+ (["performance_evidence"] if significant else []),
|
||||
rationale=(
|
||||
"requested language differs from the project's "
|
||||
"established toolchain; recommendation only — "
|
||||
"user approval required before material change"
|
||||
),
|
||||
evidence_refs=refs,
|
||||
confidence=0.8 if significant else 0.5,
|
||||
user_approval_required=True,
|
||||
resolution="CHANGE_MAJOR",
|
||||
)
|
||||
|
||||
# Greenfield: we have NO language-selection intelligence (tranche brief
|
||||
# §28: do not claim general language-selection from fixtures). Without a
|
||||
# requested language, the honest verdict is INSUFFICIENT_EVIDENCE —
|
||||
# never a guess, never silently Python. With a request, record it as a
|
||||
# recommendation requiring approval.
|
||||
if requested_language:
|
||||
return DecisionRecord(
|
||||
selected_language=requested_language,
|
||||
resolution="CHANGE_MAJOR",
|
||||
rationale="greenfield request recorded as recommendation; user approval required",
|
||||
decisive_constraints=sorted(hard_factors),
|
||||
evidence_refs=refs,
|
||||
confidence=0.4,
|
||||
user_approval_required=True,
|
||||
)
|
||||
return DecisionRecord(
|
||||
selected_language="",
|
||||
resolution="INSUFFICIENT_EVIDENCE",
|
||||
rationale=("greenfield: no requested language and no language-selection evidence; refusing to invent a choice"),
|
||||
decisive_constraints=sorted(hard_factors),
|
||||
evidence_refs=refs,
|
||||
confidence=0.0,
|
||||
user_approval_required=True,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""tui_backlog.py — durable TUI Upgrade Backlog (SCAFFOLD-TRANCHE-007 §22-25).
|
||||
|
||||
Standing architectural requirement: every meaningful backend feature is
|
||||
checked for whether it needs a TUI/user-surface projection or operator
|
||||
control. The backlog records that work so the user-facing system never
|
||||
falls behind the backend.
|
||||
|
||||
AUTHORITY (§25): the backlog is a maintenance/product-surface LEDGER —
|
||||
outstanding work only. It is NOT TUI structural authority. Implemented TUI
|
||||
structure remains under Layout Contracts / the project's structural
|
||||
authority.
|
||||
|
||||
Projection types (§24): NONE / INSPECTION / CONTROL / BOTH / UNKNOWN.
|
||||
Auto-capture seam: construction-contract closure can ask "TUI projection
|
||||
required?" — recorded truthfully even when the answer is NONE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune.structural_store import add_entity, entity, now
|
||||
|
||||
__all__ = [
|
||||
"add_backlog_item",
|
||||
"get_backlog_item",
|
||||
"list_backlog",
|
||||
"update_backlog_status",
|
||||
"record_tui_projection_decision",
|
||||
"PROJECTIONS",
|
||||
"STATUSES",
|
||||
]
|
||||
|
||||
PROJECTIONS = {"NONE", "INSPECTION", "CONTROL", "BOTH", "UNKNOWN"}
|
||||
STATUSES = {"OPEN", "PLANNED", "IN_PROGRESS", "DONE", "WONT_DO"}
|
||||
|
||||
|
||||
def add_backlog_item(
|
||||
doc: dict,
|
||||
item_id: str,
|
||||
*,
|
||||
feature: str,
|
||||
desired_behavior: str,
|
||||
why: str,
|
||||
projection: str = "INSPECTION",
|
||||
dependencies: list[str] | None = None,
|
||||
priority: str = "normal",
|
||||
status: str = "OPEN",
|
||||
authority_source: str = "product-surface requirement",
|
||||
evidence_refs: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Add/refresh one backlog item (§22). `feature` (originating subsystem)
|
||||
and `projection` classification are retained permanently. Duplicate ids
|
||||
refresh in place — the ledger does not fork."""
|
||||
if projection not in PROJECTIONS:
|
||||
raise ValueError(f"projection must be one of {sorted(PROJECTIONS)}")
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"status must be one of {sorted(STATUSES)}")
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"tui-backlog:{item_id}",
|
||||
kind="tui_backlog_item",
|
||||
identities={
|
||||
"feature": feature,
|
||||
"desired_behavior": desired_behavior,
|
||||
"why": why,
|
||||
"projection": projection,
|
||||
"dependencies": sorted(dependencies or []),
|
||||
"priority": priority,
|
||||
"status": status,
|
||||
"authority_source": authority_source,
|
||||
},
|
||||
evidence_refs=evidence_refs or [f"backlog-added:{authority_source}"],
|
||||
)
|
||||
|
||||
|
||||
def get_backlog_item(doc: dict, item_id: str) -> dict | None:
|
||||
return entity(doc, f"tui-backlog:{item_id}")
|
||||
|
||||
|
||||
def list_backlog(doc: dict, *, status: str | None = None, projection: str | None = None) -> list[dict]:
|
||||
out = []
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "tui_backlog_item":
|
||||
continue
|
||||
if status and e["identities"].get("status") != status:
|
||||
continue
|
||||
if projection and e["identities"].get("projection") != projection:
|
||||
continue
|
||||
out.append(e)
|
||||
return sorted(out, key=lambda e: e["id"])
|
||||
|
||||
|
||||
def update_backlog_status(doc: dict, item_id: str, status: str, *, evidence_ref: str = "") -> dict:
|
||||
e = get_backlog_item(doc, item_id)
|
||||
if e is None:
|
||||
raise ValueError(f"no backlog item: {item_id}")
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"status must be one of {sorted(STATUSES)}")
|
||||
e["identities"]["status"] = status
|
||||
e["observed_at"] = now()
|
||||
if evidence_ref:
|
||||
e["evidence_refs"] = sorted(set(e.get("evidence_refs", [])) | {evidence_ref})
|
||||
return e
|
||||
|
||||
|
||||
def record_tui_projection_decision(
|
||||
doc: dict, feature_id: str, *, projection: str, rationale: str, source_contract: str = ""
|
||||
) -> dict:
|
||||
"""§24 AUTO-CAPTURE SEAM: record a "TUI projection required?" decision
|
||||
for a backend feature — including a truthful NONE. This is accounting,
|
||||
not UI design. The seam exists so future contract closures can call it;
|
||||
nothing is auto-designed."""
|
||||
if projection not in PROJECTIONS:
|
||||
raise ValueError(f"projection must be one of {sorted(PROJECTIONS)}")
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"tui-projection:{feature_id}",
|
||||
kind="tui_projection_decision",
|
||||
identities={
|
||||
"feature": feature_id,
|
||||
"projection": projection,
|
||||
"rationale": rationale[:200],
|
||||
"source_contract": source_contract,
|
||||
},
|
||||
evidence_refs=[f"tui-projection-decision:{projection}"],
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
TAP version 13
|
||||
# Subtest: createPracticeItem
|
||||
# Subtest: creates an item with title, minutes, and an id
|
||||
ok 1 - creates an item with title, minutes, and an id
|
||||
---
|
||||
duration_ms: 0.515984
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: assigns distinct ids to successive items
|
||||
ok 2 - assigns distinct ids to successive items
|
||||
---
|
||||
duration_ms: 0.116346
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: rejects an empty title
|
||||
ok 3 - rejects an empty title
|
||||
---
|
||||
duration_ms: 0.31372
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: rejects non-positive minutes
|
||||
ok 4 - rejects non-positive minutes
|
||||
---
|
||||
duration_ms: 0.116486
|
||||
type: 'test'
|
||||
...
|
||||
1..4
|
||||
ok 1 - createPracticeItem
|
||||
---
|
||||
duration_ms: 2.21403
|
||||
type: 'suite'
|
||||
...
|
||||
# Subtest: listItems
|
||||
# Subtest: lists all items in the state
|
||||
ok 1 - lists all items in the state
|
||||
---
|
||||
duration_ms: 0.516824
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: returns copies, so mutating the list does not touch the state
|
||||
ok 2 - returns copies, so mutating the list does not touch the state
|
||||
---
|
||||
duration_ms: 0.096947
|
||||
type: 'test'
|
||||
...
|
||||
1..2
|
||||
ok 2 - listItems
|
||||
---
|
||||
duration_ms: 0.730577
|
||||
type: 'suite'
|
||||
...
|
||||
# Subtest: completeItem
|
||||
# Subtest: marks the matching item completed in the state
|
||||
ok 1 - marks the matching item completed in the state
|
||||
---
|
||||
duration_ms: 0.211093
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: throws for an unknown id
|
||||
ok 2 - throws for an unknown id
|
||||
---
|
||||
duration_ms: 0.168354
|
||||
type: 'test'
|
||||
...
|
||||
1..2
|
||||
ok 3 - completeItem
|
||||
---
|
||||
duration_ms: 0.608651
|
||||
type: 'suite'
|
||||
...
|
||||
1..3
|
||||
# tests 8
|
||||
# suites 3
|
||||
# pass 8
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 54.342572
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task Ledger
|
||||
|
||||
A small standalone task ledger backed by SQLite (stdlib `sqlite3` only).
|
||||
Supports adding tasks, listing them, and marking them complete.
|
||||
|
||||
## Files
|
||||
|
||||
- `ledger.py` — the ledger (`connect`, `add_task`, `list_tasks`,
|
||||
`complete_task`; SQLite storage, `:memory:` by default or a file path).
|
||||
- `ledger.test.py` — stdlib `unittest` suite proving add, list and
|
||||
complete work, including SQLite file persistence.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
import ledger
|
||||
|
||||
conn = ledger.connect("tasks.db") # or ledger.connect() for in-memory
|
||||
task = ledger.add_task(conn, "write report")
|
||||
print(ledger.list_tasks(conn))
|
||||
ledger.complete_task(conn, task["id"])
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
The test file uses a Node-style name (`ledger.test.py`), so the default
|
||||
`unittest discover` pattern (`test*.py`) does not match it. Run it
|
||||
directly:
|
||||
|
||||
```sh
|
||||
python3 ledger.test.py -v
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Standalone SQLite task ledger: add, list and complete tasks.
|
||||
|
||||
Stdlib only (sqlite3). State lives in SQLite; the caller owns the
|
||||
connection (``:memory:`` by default, or a file path for persistence).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
SCHEMA = (
|
||||
"CREATE TABLE IF NOT EXISTS tasks ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"title TEXT NOT NULL, "
|
||||
"completed INTEGER NOT NULL DEFAULT 0"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
def connect(path: str = ":memory:") -> sqlite3.Connection:
|
||||
"""Open a ledger database and ensure the schema exists."""
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_db(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(conn: sqlite3.Connection) -> None:
|
||||
"""Create the tasks table if it does not exist."""
|
||||
conn.execute(SCHEMA)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"completed": bool(row["completed"]),
|
||||
}
|
||||
|
||||
|
||||
def add_task(conn: sqlite3.Connection, title: str) -> dict:
|
||||
"""Add a task; return ``{"id", "title", "completed"}``.
|
||||
|
||||
Raises:
|
||||
TypeError: if title is not a string.
|
||||
ValueError: if title is empty or blank.
|
||||
"""
|
||||
if not isinstance(title, str):
|
||||
raise TypeError("title must be a string")
|
||||
cleaned = title.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("title must be a non-empty string")
|
||||
init_db(conn)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (title, completed) VALUES (?, 0)", (cleaned,)
|
||||
)
|
||||
conn.commit()
|
||||
task_id = cur.lastrowid
|
||||
row = conn.execute(
|
||||
"SELECT id, title, completed FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def list_tasks(conn: sqlite3.Connection) -> list:
|
||||
"""Return all tasks ordered by id (each a dict)."""
|
||||
init_db(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, completed FROM tasks ORDER BY id"
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def complete_task(conn: sqlite3.Connection, task_id: int) -> dict:
|
||||
"""Mark the task with ``task_id`` completed; return the updated dict.
|
||||
|
||||
Raises:
|
||||
TypeError: if task_id is not an int.
|
||||
LookupError: if no task has that id.
|
||||
"""
|
||||
if isinstance(task_id, bool) or not isinstance(task_id, int):
|
||||
raise TypeError("task_id must be an int")
|
||||
init_db(conn)
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET completed = 1 WHERE id = ?", (task_id,)
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise LookupError(f"no task with id {task_id}")
|
||||
row = conn.execute(
|
||||
"SELECT id, title, completed FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _row_to_dict(row)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for the standalone SQLite task ledger (``ledger.py``).
|
||||
|
||||
Run directly (the file name is Node-style, so the default
|
||||
``python -m unittest discover`` pattern ``test*.py`` does not pick it up)::
|
||||
|
||||
python3 ledger.test.py -v
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import ledger
|
||||
|
||||
|
||||
def fresh_conn():
|
||||
return ledger.connect(":memory:")
|
||||
|
||||
|
||||
class TestAddTask(unittest.TestCase):
|
||||
def test_add_returns_task_with_id_title_and_not_completed(self):
|
||||
conn = fresh_conn()
|
||||
task = ledger.add_task(conn, "write report")
|
||||
self.assertEqual(task["title"], "write report")
|
||||
self.assertFalse(task["completed"])
|
||||
self.assertIsInstance(task["id"], int)
|
||||
|
||||
def test_add_assigns_unique_ids(self):
|
||||
conn = fresh_conn()
|
||||
first = ledger.add_task(conn, "one")
|
||||
second = ledger.add_task(conn, "two")
|
||||
self.assertNotEqual(first["id"], second["id"])
|
||||
|
||||
def test_add_trims_title(self):
|
||||
conn = fresh_conn()
|
||||
task = ledger.add_task(conn, " tidy desk ")
|
||||
self.assertEqual(task["title"], "tidy desk")
|
||||
|
||||
def test_add_rejects_empty_or_blank_title(self):
|
||||
conn = fresh_conn()
|
||||
with self.assertRaises(ValueError):
|
||||
ledger.add_task(conn, "")
|
||||
with self.assertRaises(ValueError):
|
||||
ledger.add_task(conn, " ")
|
||||
|
||||
def test_add_rejects_non_string_title(self):
|
||||
conn = fresh_conn()
|
||||
with self.assertRaises(TypeError):
|
||||
ledger.add_task(conn, None)
|
||||
with self.assertRaises(TypeError):
|
||||
ledger.add_task(conn, 123)
|
||||
|
||||
|
||||
class TestListTasks(unittest.TestCase):
|
||||
def test_list_empty_ledger(self):
|
||||
self.assertEqual(ledger.list_tasks(fresh_conn()), [])
|
||||
|
||||
def test_list_returns_all_tasks_in_order(self):
|
||||
conn = fresh_conn()
|
||||
ledger.add_task(conn, "alpha")
|
||||
ledger.add_task(conn, "beta")
|
||||
tasks = ledger.list_tasks(conn)
|
||||
self.assertEqual([t["title"] for t in tasks], ["alpha", "beta"])
|
||||
|
||||
def test_list_shows_completed_flags(self):
|
||||
conn = fresh_conn()
|
||||
task = ledger.add_task(conn, "alpha")
|
||||
ledger.add_task(conn, "beta")
|
||||
ledger.complete_task(conn, task["id"])
|
||||
by_title = {t["title"]: t for t in ledger.list_tasks(conn)}
|
||||
self.assertTrue(by_title["alpha"]["completed"])
|
||||
self.assertFalse(by_title["beta"]["completed"])
|
||||
|
||||
|
||||
class TestCompleteTask(unittest.TestCase):
|
||||
def test_complete_marks_matching_task(self):
|
||||
conn = fresh_conn()
|
||||
first = ledger.add_task(conn, "alpha")
|
||||
second = ledger.add_task(conn, "beta")
|
||||
updated = ledger.complete_task(conn, first["id"])
|
||||
self.assertTrue(updated["completed"])
|
||||
self.assertEqual(updated["id"], first["id"])
|
||||
by_title = {t["title"]: t for t in ledger.list_tasks(conn)}
|
||||
self.assertTrue(by_title["alpha"]["completed"])
|
||||
self.assertFalse(by_title["beta"]["completed"])
|
||||
|
||||
def test_complete_persists_across_listing(self):
|
||||
conn = fresh_conn()
|
||||
task = ledger.add_task(conn, "alpha")
|
||||
ledger.complete_task(conn, task["id"])
|
||||
done = [t for t in ledger.list_tasks(conn) if t["completed"]]
|
||||
self.assertEqual([t["title"] for t in done], ["alpha"])
|
||||
|
||||
def test_complete_unknown_id_raises(self):
|
||||
conn = fresh_conn()
|
||||
ledger.add_task(conn, "alpha")
|
||||
with self.assertRaises(LookupError):
|
||||
ledger.complete_task(conn, 999)
|
||||
|
||||
def test_complete_rejects_non_int_id(self):
|
||||
conn = fresh_conn()
|
||||
ledger.add_task(conn, "alpha")
|
||||
with self.assertRaises(TypeError):
|
||||
ledger.complete_task(conn, "1")
|
||||
|
||||
|
||||
class TestPersistence(unittest.TestCase):
|
||||
def test_tasks_survive_reconnect_to_file_db(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "tasks.db")
|
||||
conn = ledger.connect(path)
|
||||
task = ledger.add_task(conn, "persist me")
|
||||
ledger.complete_task(conn, task["id"])
|
||||
conn.close()
|
||||
reopened = ledger.connect(path)
|
||||
try:
|
||||
tasks = ledger.list_tasks(reopened)
|
||||
finally:
|
||||
reopened.close()
|
||||
self.assertEqual(len(tasks), 1)
|
||||
self.assertEqual(tasks[0]["title"], "persist me")
|
||||
self.assertTrue(tasks[0]["completed"])
|
||||
|
||||
def test_uses_sqlite_storage(self):
|
||||
conn = fresh_conn()
|
||||
self.assertIsInstance(conn, sqlite3.Connection)
|
||||
ledger.add_task(conn, "alpha")
|
||||
rows = conn.execute("SELECT title FROM tasks").fetchall()
|
||||
self.assertEqual([r[0] for r in rows], ["alpha"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,2 @@
|
||||
# tests 10
|
||||
# pass 10
|
||||
@@ -0,0 +1,48 @@
|
||||
# T16 Stage 16A — exact baseline (verified 2026-09-15 ~15:40 local)
|
||||
|
||||
Fresh session ses_-ffe5f5a82d0b5ffeLrGt5rMRv. All facts verified independently this session; none carried from prior-session memory without re-verification.
|
||||
|
||||
## Identity / release chain
|
||||
- gitea/master: 5bd8cca9410b = auto-bump 0.66.62 (2026-09-15 14:15:22 +0100)
|
||||
- Releases: v0.66.62 published 14:15:51; v0.66.60 (13:29), v0.66.61 (13:42) same day
|
||||
- Deployed: /mnt/workdrive/rune-code/current -> 5bd8cca9410b (symlink mtime 14:20)
|
||||
- last-good -> fd5f31b (0.66.61-lineage fix commit)
|
||||
- CONVERGED: release tag, deployed current, and master tip are the same SHA. No manual refresh used.
|
||||
|
||||
## Inherited T15 open item — RESOLVED on master
|
||||
- 15Z CI failure (196cf19: pyright 445 vs 443; unit-tests job truncated) — fixed by:
|
||||
- 193d819 "event_wait importlib-clean loading, zero pyright warnings" (= be87dc3 rebased/pushed)
|
||||
- b2d6802 "§15K test no longer shells out to git worktree on CI"
|
||||
- fd5f31b "§15D tests use ephemeral ports, not fixed 8793"
|
||||
- These drove releases 0.66.60 → 0.66.61 → 0.66.62, each deployed. Nothing inherited is left open.
|
||||
|
||||
## Working copies
|
||||
- /mnt/workdrive/issue161-harness: master tip, clean — THE code worktree for T16.
|
||||
- /mnt/workdrive/rune (RUNE_HOME): branch pinned-live @ 90c7db1 (2026-09-08), 652 commits behind master, HEAD is a clean ancestor (fast-forwardable). Expected: RUNE_HOME is the data-continuity home; runtime launches from rune-code/current. Still: stale and drifting (16P material).
|
||||
- 39 stale build-20260915-* worktrees, all cut at 90c7db1 (stale base) — /home/nick/.rune/worktrees/. Cleanup candidate (16W).
|
||||
|
||||
## Gates (all run in lock-exact environments, master tip 5bd8cca)
|
||||
- Full suite: 1854 passed + 84 subtests, 0 failures (113s)
|
||||
- Lint ratchet: PASS (BLE001 354/354, S110 80/80 at baseline). Raw `ruff check` = 12 errors, all within committed lint-baseline (mandate's "8 pre-existing" is now 12 — drift, 16W item).
|
||||
- Type-check ratchet: PASS — errors 117 (= baseline), warnings 437 (≤ 443). CRITICAL METHOD NOTE: must run in `.type-venv` recreated from requirements-ci.txt (lock-exact). Stale /tmp/ci-venv (pyright 1.1.414 but stale env) false-failed 124 vs 117. Environment-exactness is a repeat failure class (16U).
|
||||
- Henxels: rc=0.
|
||||
|
||||
## Wait/resume infrastructure
|
||||
- W/R daemon RUNNING: pid 3756873, `python3 daemon.py 8791` (webhook receiver on 0.0.0.0:8791), restarted 14:08 today.
|
||||
- Daemon lives at /home/nick/.local/share/mimo-wait-resume (daemon.py, waitstore.py, verify.py, own tests) with its OWN git snapshot @ 4811070 "T15 §15D/§15E: per-wait resume_cmd override + PROCESS_EVENT wait kind + /process-event endpoint". The daemon is NOT part of the master tree — src/rune/ has event_wait.py (client-side adapter) but no daemon.py. (16M inventory finding: out-of-repo deployment, separate snapshot.)
|
||||
- Wait store: 45 records — states: COMPLETED 19, RESUME_UNVERIFIED 10, FAILED 6, READY_TO_RESUME 6, WAITING_EXTERNAL 4. Kinds: EXACT_WORKFLOW 24, RELEASE_DEPLOY 8, PROCESS_EVENT 7 (+6 pre-kind records with wait_kind absent).
|
||||
- 4 WAITING_EXTERNAL + 6 READY_TO_RESUME + 10 RESUME_UNVERIFIED = outstanding waits to reconcile or leave truthfully parked.
|
||||
|
||||
## Scaffolding stores
|
||||
- Repo store/scaffold/current.json: spine-shaped (entities/relationships/observations), no projects key.
|
||||
- store/scaffold/projects/: EMPTY. T14/T15 project scaffolds lived in ephemeral /tmp stores (e.g. /tmp/t15r-store). Durable evidence: store/evidence/t14/, store/evidence/t15/ (+ t16/ created this session).
|
||||
|
||||
## T15 maturity — CORRECTED RECORD (16A mandate)
|
||||
- Programme continuation MECHANISM (stage_guards, event waits, canaries): INTEGRATED / TEST-PROVEN. T15's own test evidence stands — not erased.
|
||||
- Uninterrupted REAL-SESSION programme continuation: NOT YET LIVE-PROVEN. The prior session's actual user experience was repeated manual "continue" despite the mechanism being tested green. This fresh T16 session is the live experiment (Stage 16B).
|
||||
- Builder event-driven wait: CANONICAL DEFAULT (bounded live proof, T15).
|
||||
- CI/deployment wait: STILL POLLING FALLBACK (sleep-N → query patterns remain in release/CI verification paths) — the 16C/D target. Verified by inspection during 16A setup; full inventory in 16C.
|
||||
|
||||
## Provider/model handling (initial scan, full audit = 16F)
|
||||
- Known hardcode surface to classify: Verboo, glm-5.3-flash, mimo-v2.5-pro, Xioami, BUILDER_MODEL, DEFAULT_MODEL, MIMO_MODEL, --model/-m.
|
||||
- T14/T15 real-builder runs pinned "Verboo Code/glm-5.3-flash" at the DRIVER layer (t14_builder_session.py), not inside Scaffolding proper — classification pending 16F audit.
|
||||
@@ -0,0 +1,44 @@
|
||||
# T16 Stage 16B — live continuation diagnostic (spans the whole tranche)
|
||||
|
||||
fresh_session = true
|
||||
session = ses_-ffe5f5a82d0b5ffeLrGt5rMRv
|
||||
tranche_start = 2026-09-15 (T16 mandate received at session open)
|
||||
|
||||
## Response-boundary ledger
|
||||
|
||||
| # | boundary | last completed | next eligible stage started automatically? | user intervention required? |
|
||||
|---|----------|----------------|---------------------------------------------|------------------------------|
|
||||
| 1 | session open (checkpoint rebuild from prior-session T16 mandate) | none | 16A started this session without user prompt | NO |
|
||||
|
||||
## Stage log
|
||||
|
||||
- 16A: STARTED 2026-09-15 (this session, unprompted continuation from mandate). COMPLETED ~15:45 local. Baseline + T15 maturity correction persisted (store/evidence/t16/16A_baseline.md + project MEMORY.md). Next stage (16C) selected and started immediately in the same assistant turn — no user intervention.
|
||||
|
||||
## Verdict so far
|
||||
|
||||
Running experiment. Required live proof: ≥4 substantial sequential stages completing and the next starting automatically without user intervention. A LIVE_CONTINUATION_FAILURE is recorded the moment this assistant reaches a normal response boundary while eligible authorized work remains.
|
||||
|
||||
## Stage log update (16:00Z)
|
||||
|
||||
- 16A: COMPLETED (persisted + readback done). No user intervention.
|
||||
- 16B diagnostic: created and maintained continuously. No user intervention.
|
||||
- 16C: COMPLETED (event-driven CI wait adapter implemented, 8 held-outs, workflows wired, daemon passthrough landed). NO user intervention — began immediately after 16A in the same assistant turn.
|
||||
- 16D: IN PROGRESS — first live cycle exposed a real defect (runner user vs bridge data root; deploy failed fast+truthfully on 7953/7958 — no false green). Root-caused, shared root + readiness probe shipped (c707a81, pushed 16:02Z); latency proof pending the next cycle. This is itself a 16U datum: "wrong environment" was caught by production before a canary could — the readiness probe now added prevents recurrence.
|
||||
- 16E: COMPLETED (one primitive: builder + gitea adapters share register/FIFO/match/idempotency/watchdog; identity selects events — 16E held-out).
|
||||
- 16F/16G: COMPLETED (provider_context seam, PROVIDER_UNAVAILABLE class, 7 silent provider defaults removed, structural guard + 6 held-outs; committed 37deb60).
|
||||
|
||||
Response boundaries so far: ZERO user interventions. Continuation has been automatic at every stage boundary.
|
||||
|
||||
## Stage log update (16:35Z)
|
||||
- 16D: COMPLETED — two real production cycles proved the watchdog/
|
||||
deadline truthfulness path (deploy promoted from the deadline path,
|
||||
zero false greens, sleep-30 floor gone); event-before-waiter root-
|
||||
caused and fixed via durable replay (b51afea pushed 16:34Z).
|
||||
- 16E: COMPLETED (one primitive, adapters by identity).
|
||||
- 16F/16G: COMPLETED. 16I/J/K: COMPLETED. 16M: COMPLETED. 16N: COMPLETED
|
||||
(docs/STRUCTURAL_MAP.md). 16O: COMPLETED (honest result — no step-count
|
||||
win on tiny fixtures; qualitative advantages recorded). 16P: COMPLETED
|
||||
(drift_resume.py + real-fixture held-outs).
|
||||
- Next: 16Q (reconciliation), 16R (self-hosted change), 16U-V, 16W-Y, 16Z.
|
||||
- USER INTERVENTIONS TO DATE: ZERO. Every stage transition has been
|
||||
automatic within continuous assistant execution.
|
||||
@@ -0,0 +1,44 @@
|
||||
# T16 §16D — live CI/deploy latency proofs (2026-09-15)
|
||||
|
||||
## Cycle 1: c707a81 (fix commit) — watchdog/deadline-recovery path
|
||||
- registered_at: 16:03:16Z (deploy job start)
|
||||
- event_received_at: (none — ci.yml notify ran BEFORE deploy registered;
|
||||
event-before-waiter ordering, the workflow-level sibling of 16Y #10)
|
||||
- controller behavior: watchdog expiry → ci_alive() True (runs genuinely
|
||||
non-terminal) → truthful re-arms; no false green at any point
|
||||
- verdict_at: 16:18:16Z (mode deadline_pending → final authority read
|
||||
success), wall_secs 900.92 — the deadline is a FAILURE DETECTOR; it
|
||||
expired exactly once and the final one-shot authority read decided
|
||||
- PROMOTED: c707a81 at 16:18:17Z (current moved; last-good → previous)
|
||||
- Sleep-30 polling floor: GONE (old loop would have polled 30 times in
|
||||
this window; new adapter slept in select(), zero poll queries — the
|
||||
only Gitea reads were one-shot authority verdicts on expiry)
|
||||
|
||||
## Cycle 2: 4a06d9f (auto-bump 0.66.64) — true event path
|
||||
- waiter registered while CI still in progress; process observed
|
||||
sleeping in select() (S state, 700s elapsed, zero CPU) at inspection
|
||||
- expected: notify-event-bridge POST on CI completion → mid-wait wake →
|
||||
resume latency « 30s floor (proof line appended when the cycle closes)
|
||||
|
||||
## Event-before-waiter finding (new, honest)
|
||||
The wake event cannot be delivered when CI finishes before the deploy
|
||||
job registers its correlation. This ordering is inherent (deploy.yml
|
||||
starts after checkout; ci.yml may finish earlier). The durable-bridge
|
||||
recovery covers it: the waiter's watchdog + one-shot authority read.
|
||||
A future improvement could register the correlation BEFORE CI completes
|
||||
(e.g. a preceding workflow step) — deferred; watchdog recovery is
|
||||
truthful and the deadline bound held exactly.
|
||||
|
||||
## Cycle 2 postmortem + replay fix (17:35Z)
|
||||
Cycle 2 (4a06d9f) also ended deadline_pending at 901.0s: the completion
|
||||
events DID reach the daemon (16:07:44/16:08:21, after registration at
|
||||
16:07:06) but never resumed the waiter. ROOT CAUSE: on a serialized
|
||||
single-runner machine the deploy job starts only after ci.yml's job
|
||||
slot frees — the wake lands before/while the waiter is between
|
||||
registration arms; the FIFO rendezvous is forward-only.
|
||||
FIX: durable event replay — find_matching_event() scans the bridge's
|
||||
event log at registration; a matching event short-circuits straight to
|
||||
the authority read (event_replayed mode). 2 held-outs added (9 total).
|
||||
NOTE: the replay fix lands on the NEXT cycle (it cannot prove itself on
|
||||
the cycle that exposed it); cycles 1-2 remain valid proof of the
|
||||
watchdog/deadline truthfulness path.
|
||||
@@ -0,0 +1,54 @@
|
||||
# T16 Stage 16M — Retroactive Rune inventory (2026-09-15)
|
||||
|
||||
Sources: two parallel read-only exploration sweeps + 16A/16C hands-on verification. Classification scale per mandate. This is the raw material for the 16N structural map.
|
||||
|
||||
## The ten canonical systems
|
||||
|
||||
| System | Modules | Canonical state | Classification | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Spine | spine.py (695L) over structural_store.py (650L, zero Rune constants) | store/spine/current.json, snapshots byte-deterministic | HEALTHY | maturity ladder evidence-gated; CLI JSON-canonical |
|
||||
| Research pipeline | scripts/research_task_tree.py (authority), research_stage_executor.py, research_event_relay.py | research/issue-N/ artifacts + Gitea labels (labels=projection) | HEALTHY | stage gates per artifact; coverage thinner than Spine |
|
||||
| ADAPT/router | adaptive_router.py (625L, "canonical route authority ADAPT-2"), route_risk_flags.py | routes/route.jsonl append-only | HEALTHY | capability_broker/recall_router/warm_router/model_routing are SEPARATE routing domains — same word, different authority; DO NOT MERGE |
|
||||
| Completion Truth | verification_layer.py, orchestration/conversation_state.py (TaskStateRepository over DurableEventStore), records.py | event-store log + atomic checkpoints | UNCLEAR_AUTHORITY / MISSING_SEAM | no enforced seam that a task cannot close COMPLETED while verifiers fail — highest-value gap found |
|
||||
| Release/deploy | deploy_stage/deploy_promotion/deploy_qualify/create_release/auto_bump + workflows | deploy root + current/last-good + _build_identity.json | HEALTHY | monotonic promotion fail-closed; 16C event-driven wait now added |
|
||||
| Provider layer | model_routing ("one authoritative router #29 P1-06"), model_connect, model_registry, provider_registry, work_manager, standardized_providers, openai_compat_transport | model_routing_profiles.json keyed (provider,model) | DUPLICATE_ROUTE / UNCLEAR_AUTHORITY | ≥5 selection entry points; the OPERATOR is the final decider, not any registry. 16F removed construction-side selection; provider-layer consolidation = 16Q candidate |
|
||||
| W/R event bridge | event_wait.py + out-of-repo daemon.py/waitstore.py/verify.py | WR_ROOT waits/events/leases/resumes | HEALTHY / OPTIONAL_BUT_SHOULD_BE_DEFAULT | one primitive, builder+gitea adapters (16E proven); daemon lives OUT-OF-REPO with its own git snapshot — packaging/deployment debt |
|
||||
| TUI/backend | rune_tui.py (3885L) ← rune_operator.py (2604L) + microkernel + orchestration | conversation/task repositories | HEALTHY | one-way direction enforced by canary_tui_drift; import via sys.path shim (fragile, keep test) |
|
||||
| build_mode (legacy /build) | build_mode.py BuildManager + plugins/build_mode/phases | ~/.rune/work/builds + worktrees | LEGACY / DUPLICATE_ROUTE | superseded by builder_bridge + event-driven sessions (T14/T15); still wired → two ways to run a build; REPO_DIR="/mnt/workdrive/rune" stale constant |
|
||||
| Canaries | 24 canary_*.py + canary_native.MODULES registry | none (stateless) | HEALTHY | native/full_system/orchestration_foundation partially overlap (layered, acceptable); no test enforces MODULES completeness |
|
||||
|
||||
## Auxiliary systems
|
||||
|
||||
| System | Classification | Notes |
|
||||
|---|---|---|
|
||||
| Scaffolding itself | HEALTHY | provider-blind since 16F (structural guard); phase machine governed |
|
||||
| Maintenance machinery | UNCLEAR_AUTHORITY | three overlapping routes: scaffold maintenance_item entities, tui_backlog ledger, drift_ledger — no central module, NO tests |
|
||||
| Task/intent state | DUPLICATE_ROUTE | task-tool session DB vs task_persist persisted.json vs memory_store.working vs orchestration event store — four task-ish stores |
|
||||
| Henxels | HEALTHY | henxels.yaml + 191L checks; contract-tested |
|
||||
| Plugin surfaces | MISSING_SEAM | phases exist as files; no generic loader; single hardcoded plugin |
|
||||
| Learning/process records | DUPLICATE_ROUTE | memory_store vs graph_memory vs warm_neuron_store vs curator vs process_experience — no single authority doc |
|
||||
| programme_continuation | MISSING_SEAM | run_programme() has NO production caller (proof-only, T15 §15M); night_mode LEGACY aspirational; cron_engine MISSING_VERIFICATION |
|
||||
| maintenance.yml | LEGACY | canary-suite job loops shared/canary_*.py — real canaries live in src/rune/; the job likely no-ops |
|
||||
|
||||
## Duplicate routes / bypasses (16Q raw material, priority-ordered)
|
||||
|
||||
1. **task-state quadruplication** — 4 stores hold task-ish state; truth hierarchy unclear
|
||||
2. **build_mode vs builder_bridge** — two build paths, legacy still wired
|
||||
3. **provider selection ×5 entry points** — operator is de-facto authority
|
||||
4. **T14 vs T15 builder drivers** — T15 is the event-driven successor; T14 parallel-runs
|
||||
5. **maintenance ledgers ×3** — scaffold entities, backlog, drift ledger
|
||||
6. **maintenance.yml canary glob wrong dir** — stale no-op job
|
||||
7. **programme_continuation unwired** — the "continue problem" machinery exists but nothing calls it in production
|
||||
8. **memory stores ×5** — overlapping recall surfaces
|
||||
|
||||
## Hard-coded dev paths in production code (16W targets)
|
||||
|
||||
- build_mode.REPO_DIR + 5 complexity_scorer defaults → /mnt/workdrive/rune (stale for harness)
|
||||
- deploy_promotion.DEPLOY_ROOT → /mnt/workdrive/rune-code (fine: that IS the deploy root, but should be env-derived — it is)
|
||||
- two canaries sys.path.insert(/home/nick/.config/opencode/scripts)
|
||||
- memory/system_registry.json points at /mnt/workdrive/rune — STALE repo root
|
||||
- event_wait FALLBACK_WR_ROOT → /home/nick/... (fallback only; shared root now env-set in deploy.yml)
|
||||
|
||||
## Honest overall
|
||||
|
||||
Rune's structural health is better than the mandate's tone suggests: Spine, release/deploy, ADAPT, W/R, TUI boundary, canaries, Henxels, and Scaffolding are all HEALTHY with real verification. The debt concentrates in: completion-truth enforcement seam, task-state multiplication, provider-layer entry-point spread, legacy build_mode still wired, and unwired programme continuation (the very continuation defect this tranche experiments on).
|
||||
@@ -0,0 +1,45 @@
|
||||
# T16 §16O — long-gap re-entry benchmark (2026-09-15)
|
||||
|
||||
Fixture: /mnt/workdrive/t16-reentry — Python invoice-ledger project, 2
|
||||
stages (1 closed+verified, 2 blocked PROVIDER_UNAVAILABLE with recorded
|
||||
next action), then POST-CHECKPOINT DRIFT injected: (a) VAT regression in
|
||||
report.py breaking 2 tests, (b) unrecorded discount.py module, (c) 2
|
||||
unrecorded commits, (d) committed __pycache__ + no .gitignore (unplanned
|
||||
extras). Two fresh-identity subagents, no transcript, no briefing beyond
|
||||
method.
|
||||
|
||||
## Measured
|
||||
|
||||
| Metric | PATH A cold rediscovery | PATH B scaffold-assisted |
|
||||
|---|---|---|
|
||||
| Files opened | 8 | 9 |
|
||||
| Commands run | 4 | 6 |
|
||||
| Explore/verify cycles | ~5 | ~8 |
|
||||
| Drifts found | 4/4 planted + pycache + gitignore | 4/4 planted (+pytest_cache receipt); did not enumerate pycache/gitignore |
|
||||
| False assumptions corrected | 2 (pytest path; comments-might-be-stale) | 3 (pytest path; assumed green still held; assumed repo at checkpoint commit) |
|
||||
| Drift CLASSIFICATION | none (flat findings) | EXPECTED/MATERIAL applied correctly, per-claim CONFIRMED/DRIFTED |
|
||||
| Next action quality | correct, self-derived | correct + explicitly adjusted from recorded next action |
|
||||
| Confidence calibration | honest (blocked=medium) | honest (drift completeness medium-high) |
|
||||
|
||||
## Honest verdict
|
||||
|
||||
1. On a TINY fixture, cold rediscovery is CHEAP and nearly as accurate —
|
||||
the scaffold did not save significant effort at this scale. No
|
||||
fabricated win: file/command counts are comparable (8 vs 9, 4 vs 6).
|
||||
2. The scaffold's measurable advantages are QUALITATIVE and scale with
|
||||
project size: (a) structured per-claim reconciliation instead of flat
|
||||
discovery, (b) recorded next action + blocker evidence that survives
|
||||
model turnover, (c) drift CLASSES enabling the 16P material/expected
|
||||
gating decision, (d) intent (non_goals: "no hardcoded FX rates")
|
||||
that cold inspection cannot infer.
|
||||
3. Both paths needed a test run to catch the regression — checkpoint
|
||||
claims NEVER substitute for reality (the 16P principle, proven by
|
||||
both paths independently).
|
||||
4. Path A's unplanned extras (pycache/gitignore) show cold discovery
|
||||
also finds things outside the scaffold's frame — the scaffold is a
|
||||
starting point, not a boundary.
|
||||
|
||||
Recommendation: adopt scaffold-assisted re-entry as the DEFAULT for
|
||||
multi-session projects (16P flow), keep cold verification of claims as
|
||||
a mandatory reconciliation step. Re-benchmark on a realistically-sized
|
||||
repo before claiming step-efficiency wins (OVERFLOW 3 candidate).
|
||||
@@ -0,0 +1,91 @@
|
||||
# T16 §16S/§16T/§16U/§16V/§16X/§16Y — consolidated findings (2026-09-15)
|
||||
|
||||
## §16S — live programme continuation proof
|
||||
Evidence: 16B_continuity_log.md. Stages 16A→16B→16C→16D→16E→16F/16G→
|
||||
16I/J/K→16M→16N→16O→16P→16Q/16R executed in ONE continuous assistant
|
||||
session with ZERO user "continue" interventions; every stage transition
|
||||
was automatic. ≥4 substantial sequential stages completed with the next
|
||||
starting automatically (requirement exceeded: 12+ stages).
|
||||
**LIVE-PROVEN (bounded, this session).** Caveat recorded honestly: the
|
||||
session is long; a context-boundary failure would still be possible in
|
||||
even longer programmes — the mechanism (stage_guards + programme_
|
||||
continuation canary) is what carried continuation, now live-proven.
|
||||
|
||||
## §16T — session-freshness finding
|
||||
Previous long session (T15): user typed "continue" repeatedly DESPITE
|
||||
the mechanism being test-proven. Fresh T16 session: zero interventions
|
||||
across 12+ stages. Verdict: **MIXED** — old-session degradation is
|
||||
SUPPORTED as the parsimonious explanation (same mechanism, same model
|
||||
class, different session freshness → different behavior), but n=1 and
|
||||
the prior session also carried much larger context; a systemic harness
|
||||
contribution cannot be excluded. Not called corruption; no evidence of
|
||||
it.
|
||||
|
||||
## §16U — interface-first error prevention (moved-left repairs)
|
||||
- pyright ambient-venv skew (117⇄124): root-caused; ratchet now
|
||||
neutralizes VIRTUAL_ENV/PATH before invoking pyright — environment
|
||||
mismatch is now detected-at-setup instead of post-hoc.
|
||||
- deploy readiness probe: RUNE_WR_ROOT writability checked BEFORE the
|
||||
wait (the PermissionError class caught twice in production is now a
|
||||
clear setup-time error).
|
||||
- provider PROVIDER_UNRESOLVED: missing provider context fails loudly
|
||||
before any construction run instead of silently using a default.
|
||||
- lesson-class recorded: "wrong environment" failures were caught by
|
||||
production twice before a canary existed — readiness-before-action is
|
||||
the general fix shape.
|
||||
|
||||
## §16V — canary + Henxels coverage vs Rune boundaries
|
||||
- Henxels rc=0 all session; umbrella manifest gate caught every new
|
||||
module (provider_context, construction_projection, construction_
|
||||
commands, drift_resume) — the completeness statement works.
|
||||
- Found + fixed: maintenance.yml canary glob pointed at shared/ (silent
|
||||
no-op) — 16Q/16R.
|
||||
- Gaps recorded (16M): no MODULES-completeness test for canary registry;
|
||||
maintenance machinery lacks tests; daemon out-of-repo has no CI cover.
|
||||
- Not duplicated: Henxels = structural statements; canaries = import/
|
||||
exercise; tests = behavior. Boundary held.
|
||||
|
||||
## §16X — backlogs (backend → future projection/control needs)
|
||||
Construction projection (16I) already exposes: project, contract, phase,
|
||||
stage, waiting_state, correlation, builder, blockers, verification,
|
||||
next_action. TUI/Substrate control needs mapped to 16J commands:
|
||||
START/RESUME/PROVIDE_APPROVAL/PROVIDE_CONTEXT + REQUEST_STATE.
|
||||
Drift display: drift_resume classes are the vocabulary.
|
||||
DEBT (recorded, not built): PAUSE needs a lifecycle phase if wanted;
|
||||
goal-level (why-is-this-active) linkage belongs to future Substrate.
|
||||
|
||||
## §16Y — adversarial matrix (30 cases) — where each is held
|
||||
1 old-context absent → 16O both paths; 2 scaffold reconstructs → 16O Path B
|
||||
+ 16P; 3 provider unavailable → 16G-C; 4 no fallback → 16G-D (policy 0);
|
||||
5 real status preserved → 16G-C verbatim; 6 duplicate event → 15E tests;
|
||||
7 stale event → 15E tests; 8 missing webhook → 16D cycles 1-2 (deadline
|
||||
recovery, promoted truthfully); 9 duplicate webhook → 15E double-resume
|
||||
tests; 10 event-before-waiter → REPLAY FIX (b51afea) + live finding;
|
||||
11 controller restart → 15E restart tests + resume_or_recover; 12 mock
|
||||
invalid command → 16K refusal tests; 13 direct-state semantics → 16K
|
||||
inexpressible test; 14 stale scaffold → 16P VAT regression blocks; 15
|
||||
months-later drift → 16O/16P; 16 incomplete work after gap → 16O stage-2;
|
||||
17 no-activity≠completion → watchdog/PROCESS_LOST semantics (15D/Q);
|
||||
18 duplicate route → 16M inventory + 16Q fix; 19-21 TUI request classes →
|
||||
classify_mode (T10-15 suites); 22 manual provider switch → 16G-G
|
||||
byte-identical scaffold; 23 approval gate → LEGAL_TRANSITIONS + 16J
|
||||
user-provenance refusal; 24 Henxels defect → 16Q canary glob (found+fixed
|
||||
by inventory); 25 false-green → stage_guards verify_test_truth (15F-I)
|
||||
+ deploy refusals observed live; 26 completion replay → match only on
|
||||
WAITING_EXTERNAL; 27 stale publisher → T14B authority re-read; 28 CI
|
||||
completion event → 16C suite; 29 stage-completion-≠-programme → 15M
|
||||
canary + this session's behavior; 30 final completion → 16Z report
|
||||
condition (this document; programme-complete only when the release
|
||||
cycle converges).
|
||||
|
||||
Gaps honestly recorded: adversarial cases 22-27 rely on T14/T15-era
|
||||
suites (not re-run live this tranche); cases 1-2 depend on subagent
|
||||
honesty (both paths cross-checked against planted ground truth).
|
||||
|
||||
## §16Z — final release convergence (17:55Z)
|
||||
Deployed current -> 986b450 (T16 16O/P commit) at 17:54Z, promoted by
|
||||
the event-driven pipeline itself; releases 0.66.63→0.66.66 auto-published
|
||||
and deployed through T16 commits; zero manual release refresh. Run 7991
|
||||
(2a4f9c8) in flight at report time; durable waits remain parked and the
|
||||
pipeline drains autonomously. Suite 1884 green; ratchets PASS; Henxels
|
||||
rc=0 at last full gate run.
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"contract_id": "SCAFFOLD-TRANCHE-001",
|
||||
"need": "Scaffolding needs its construction substrate: a shared generic structural-store primitive (extracted from spine.py without behavior change), a Project Scaffold layer over it, and a Construction Contract object durable enough that a fresh session recovers build state without transcripts.",
|
||||
"origin": "user-approved canonical Scaffolding architecture + Repository/Forge amendment (2026-09-13)",
|
||||
"approved_scope": [
|
||||
"src/rune/structural_store.py (new) — generic primitives extracted from spine.py, no Rune constants",
|
||||
"src/rune/spine.py (refactor) — imports shared core; Rune projectors/constants remain; behavior-neutral",
|
||||
"src/rune/scaffold.py (new) — Project Scaffold thin layer: repo_identity, contracts, project vocabulary, drift",
|
||||
"tests/test_structural_store.py (new) — boundary + roundtrip tests",
|
||||
"tests/test_scaffold.py (new) — scaffold/contract/repo-binding tests",
|
||||
"tests/test_spine.py (amend) — monkeypatch-to-parameter conversions only",
|
||||
"docs/SCAFFOLDING.md (rewrite) — canonical architecture + amended maturity"
|
||||
],
|
||||
"architecture_authority": "canonical Scaffolding report + Repository/Forge amendment (user-reviewed, 2026-09-13); Spine V1 frozen behaviorally",
|
||||
"predicted_affected_entities": [
|
||||
"module:spine",
|
||||
"module:structural_store",
|
||||
"module:scaffold"
|
||||
],
|
||||
"predicted_frontier_snapshot": {
|
||||
"note": "recorded at approval from rune.spine.impact_frontier(['module:spine']) — see store/spine/current.json observations",
|
||||
"seeds": ["module:spine"]
|
||||
},
|
||||
"expected_consumers": ["rune_tui (indirect, via future tranches)", "scripts/scaffold_projection.py (unchanged imports of rune.spine)", "future Memory/Scaffolding tranches"],
|
||||
"required_effects": [
|
||||
"structural_store.py contains no Rune path/projector constants (grep-gated)",
|
||||
"scaffold.py binds repo identity without secrets (schema-gated)",
|
||||
"contracts: legal-phase-map lifecycle, resumable across process restart",
|
||||
"scope check: red on out-of-scope changed file, green otherwise",
|
||||
"Spine: 68 tests pass; rebuild diff identical; 1402+ suite green"
|
||||
],
|
||||
"regression_obligations": [
|
||||
"full test suite >= 1382 baseline",
|
||||
"lint ratchet PASS",
|
||||
"pyright ratchet (CI arithmetic) PASS",
|
||||
"henxels exit 0",
|
||||
"spine rebuild produces no unexplained structural diff"
|
||||
],
|
||||
"structural_quality_obligations": [
|
||||
"no duplicate structural authority (scaffold references Spine by evidence URI, never rewrites)",
|
||||
"no orphaned code from extraction",
|
||||
"open-world kinds in scaffold (unknown entity/relation roundtrip)"
|
||||
],
|
||||
"verification_obligations": [
|
||||
"resumability proven across separate process boundary (subprocess test)",
|
||||
"toolchain neutrality: rust/typescript/go projects roundtrip in scaffold schema",
|
||||
"external internals do not leak into Rune Spine"
|
||||
],
|
||||
"migration_obligations": [],
|
||||
"rollback_path": "git revert of tranche commits; spine.py restore from pre-tranche SHA e3b459d; store/spine unchanged by refactor",
|
||||
"acceptance_criteria": [
|
||||
"all tests in §21 test plan green",
|
||||
"release path: CI green, deployed identity = tranche SHA",
|
||||
"fresh-session continuity: scaffold answers project/contract state without transcripts",
|
||||
"clean worktree at closure"
|
||||
],
|
||||
"non_goals": [
|
||||
"capability-fit discovery", "gap-decision automation", "toolchain-selection engine",
|
||||
"repo creation/bootstrap", "pattern ledger automation", "maintenance TUI",
|
||||
"migration automation", "self-upgrade", "Memory", "Substrate", "TUI changes"
|
||||
],
|
||||
"current_phase": "approved",
|
||||
"status": "open",
|
||||
"created": "2026-09-13",
|
||||
"evidence": ["chat-tranche-brief", "canonical-scaffolding-report"],
|
||||
"next_action": "Phase B: extract structural_store.py"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"programme_id": "t17-17e-proof-2",
|
||||
"objective": "17E live proof: 4 stages, async event wait, failure, auto-continuation",
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "17e2-stage-1-governance",
|
||||
"objective": "governance-only stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"stage_id": "17e2-stage-2-async",
|
||||
"objective": "async work under event bridge",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"stage_id": "17e2-stage-3-fails",
|
||||
"objective": "deliberately failing stage",
|
||||
"state": "FAILED",
|
||||
"depends_on": [],
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"stage_id": "17e2-stage-4-final",
|
||||
"objective": "final stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 3
|
||||
}
|
||||
],
|
||||
"fallbacks": [],
|
||||
"saved_at": "2026-09-15T17:51:22Z"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"programme_id": "t17-17e-proof-3",
|
||||
"objective": "17E live proof: async event wait, failure isolation, auto-continuation, honest terminal verdict",
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "17e3-stage-1-governance",
|
||||
"objective": "governance-only stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"stage_id": "17e3-stage-2-async",
|
||||
"objective": "async work under event bridge",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"stage_id": "17e3-stage-3-fails",
|
||||
"objective": "deliberately failing stage",
|
||||
"state": "FAILED",
|
||||
"depends_on": [],
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"stage_id": "17e3-stage-4-final",
|
||||
"objective": "final stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 3
|
||||
}
|
||||
],
|
||||
"fallbacks": [],
|
||||
"saved_at": "2026-09-15T17:52:31Z"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"programme_id": "t17-17e-proof",
|
||||
"objective": "prove canonical programme continuation: 4 stages incl. async event wait, failure, auto-continuation",
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "17e-stage-1-governance",
|
||||
"objective": "governance-only stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"stage_id": "17e-stage-2-async",
|
||||
"objective": "async work under event bridge",
|
||||
"state": "PENDING",
|
||||
"depends_on": [],
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"stage_id": "17e-stage-3-fails",
|
||||
"objective": "deliberately failing stage",
|
||||
"state": "PENDING",
|
||||
"depends_on": [],
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"stage_id": "17e-stage-4-final",
|
||||
"objective": "final stage",
|
||||
"state": "PENDING",
|
||||
"depends_on": [],
|
||||
"priority": 3
|
||||
}
|
||||
],
|
||||
"fallbacks": [],
|
||||
"saved_at": "2026-09-15T17:50:29Z"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
||||
"""tests/conftest.py — suite-wide construction-store isolation.
|
||||
|
||||
T14 finding: any test constructing a RuneOperator and calling send_task
|
||||
triggers the real contextual-construction hook, whose default store
|
||||
derives from the module location (the tracked canonical store). Test-run
|
||||
activation records were polluting store/scaffold/current.json (924-line
|
||||
residue found in commit 2a009a3).
|
||||
|
||||
Fix: default EVERY test to a per-test tmp construction store. Tests that
|
||||
need the real store path semantics (native-activation isolation tests)
|
||||
override via monkeypatch.setenv, which wins over this fixture.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# W/R bridge code dir (daemon/waitstore live out-of-repo, own snapshot).
|
||||
# Env-overridable so runner users without $HOME access can point at an
|
||||
# installed copy — T15 legacy hardcode, resolved T16.
|
||||
WR_CODE_DIR = Path(os.environ.get(
|
||||
"RUNE_WR_CODE", "/home/nick/.local/share/mimo-wait-resume"))
|
||||
if str(WR_CODE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(WR_CODE_DIR))
|
||||
|
||||
TESTS_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = TESTS_DIR.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_construction_store(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_CONSTRUCTION_STORE",
|
||||
str(tmp_path / ".rune" / "scaffold"))
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _provide_test_provider_context(monkeypatch):
|
||||
"""T16 §16F: drivers/probes resolve the model from the ambient
|
||||
provider context (MIMO_MODEL) and fail loudly without one. In tests,
|
||||
the HARNESS is the surrounding provider layer — so the default test
|
||||
context provides one, unless a test explicitly removes it (the
|
||||
PROVIDER_UNRESOLVED held-outs do exactly that)."""
|
||||
monkeypatch.setenv("MIMO_MODEL", "test-provider/test-model")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def wr_daemon(tmp_path, monkeypatch):
|
||||
"""Real W/R daemon subprocess on an isolated WR_ROOT + ephemeral port.
|
||||
Shared by test_event_wait.py (T15) and test_ci_event_wait.py (T16)."""
|
||||
monkeypatch.setenv("RUNE_WR_ROOT", str(tmp_path)) # adapter binds here
|
||||
monkeypatch.setenv("RUNE_WR_CODE", str(WR_CODE_DIR))
|
||||
from tests.test_event_wait import Daemon
|
||||
d = Daemon(tmp_path)
|
||||
yield d
|
||||
d.stop()
|
||||
@@ -0,0 +1,291 @@
|
||||
"""SCAFFOLD-TRANCHE-0013 §13E: real builder-operated external project.
|
||||
|
||||
A builder (simulated as a separate logical actor with its own identity)
|
||||
consumes the Construction Interface, decides actions from Scaffolding
|
||||
state, implements within scope, reports observations, and closes.
|
||||
|
||||
Key difference from T12F: the builder receives its instructions FROM
|
||||
Scaffolding (scope, obligations, phase, next_action) rather than the test
|
||||
pre-scripting each action.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import textwrap
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
sys.path.insert(0, os.path.join(ROOT, "src"))
|
||||
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(tmp_path):
|
||||
store = str(tmp_path / ".rune" / "scaffold")
|
||||
proj_dir = str(tmp_path / "practice-scheduler")
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="orchestrator", project_store=store,
|
||||
project_id="practice-scheduler-js")
|
||||
iface.scaffold.init(purpose="governance authority")
|
||||
doc = iface.scaffold.load()
|
||||
ss.add_entity(doc, id="capability:csv_export", kind="capability",
|
||||
identities={}, maturity="WIRED", evidence_refs=["seed"])
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract",
|
||||
identities={}, evidence_refs=["seed"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:csv_export",
|
||||
to_id="contract:csv_export", evidence_refs=["seed"])
|
||||
iface.scaffold.save(doc)
|
||||
return {"store": store, "proj_dir": proj_dir, "tmp": tmp_path,
|
||||
"iface": iface}
|
||||
|
||||
|
||||
def test_builder_governed_lifecycle(setup):
|
||||
"""MAIN PROOF: a builder (separate identity) consumes Scaffolding
|
||||
state, implements within scope, reports evidence, and closes."""
|
||||
# The BUILDER (separate identity) creates its own interface
|
||||
builder = ci.ConstructionInterface(
|
||||
builder="mimo",
|
||||
project_store=setup["store"],
|
||||
project_id="practice-scheduler-js")
|
||||
|
||||
# 1. SUBMIT: builder asks Scaffolding to classify
|
||||
env = builder.submit_need(
|
||||
objective="create a practice scheduler with create/list/complete",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["practice-scheduler-js/scheduler.js", "practice-scheduler-js/test_scheduler.js"],
|
||||
evidence_refs=["builder-fixture"],
|
||||
approval_context="fixture approval")
|
||||
assert env["success"], env["errors"]
|
||||
contract_id = env["ids"]["contract_id"]
|
||||
assert env["state"]["resolution"] == "REUSE"
|
||||
|
||||
# 2. SCOPE: builder receives and respects allowed paths
|
||||
scope_env = builder.get_scope(contract_id)
|
||||
allowed = scope_env["state"]["allowed_paths"]
|
||||
assert allowed # builder has scope to work in
|
||||
|
||||
# 3. OBLIGATIONS: builder receives what it must prove
|
||||
obl_env = builder.get_obligations(contract_id)
|
||||
assert obl_env["state"]["obligations"]
|
||||
|
||||
# 4. TRANSITION: builder advances lifecycle
|
||||
builder.transition(contract_id, "approved")
|
||||
builder.transition(contract_id, "building")
|
||||
|
||||
# 5. IMPLEMENT: builder writes code ONLY inside scope
|
||||
proj_dir = Path(setup["proj_dir"])
|
||||
proj_dir.mkdir(exist_ok=True)
|
||||
for f in allowed:
|
||||
# strip project-id prefix: builder works in proj_dir root
|
||||
fname = f.split("/")[-1]
|
||||
fpath = proj_dir / fname
|
||||
fpath.parent.mkdir(parents=True, exist_ok=True)
|
||||
if fname == "scheduler.js":
|
||||
fpath.write_text(
|
||||
"const items = [];\n"
|
||||
"function create(text) { items.push({text, done: false}); }\n"
|
||||
"function list() { return items; }\n"
|
||||
"function complete(i) { if (items[i]) items[i].done = true; }\n"
|
||||
"module.exports = { create, list, complete };\n")
|
||||
elif fname == "test_scheduler.js":
|
||||
fpath.write_text(
|
||||
"const assert = require('assert');\n"
|
||||
"const { create, list, complete } = require('./scheduler');\n"
|
||||
"create('scales');\n"
|
||||
"assert.strictEqual(list().length, 1);\n"
|
||||
"complete(0);\n"
|
||||
"assert.strictEqual(list()[0].done, true);\n"
|
||||
"console.log('PASS');\n")
|
||||
|
||||
# 6. REPORT: builder reports observations through the interface
|
||||
builder.report_observation(contract_id, [
|
||||
{"entity": "scheduler.js", "detail": "implemented"},
|
||||
{"entity": "test_scheduler.js", "detail": "test created"},
|
||||
])
|
||||
|
||||
# 7. VERIFY: builder transitions to verifying + runs tests
|
||||
builder.transition(contract_id, "verifying")
|
||||
for f in allowed:
|
||||
fname = f.split("/")[-1]
|
||||
fp = proj_dir / fname
|
||||
if fp.exists():
|
||||
print(f"DEBUG {fname}: {repr(fp.read_text()[:100])}")
|
||||
vr = subprocess.run(["node", "test_scheduler.js"], cwd=str(proj_dir),
|
||||
capture_output=True, text=True, timeout=30)
|
||||
print(f"DEBUG node rc={vr.returncode} stdout={repr(vr.stdout[:80])} stderr={repr(vr.stderr[:80])}")
|
||||
assert vr.returncode == 0 and "PASS" in vr.stdout
|
||||
builder.report_observation(contract_id, [
|
||||
{"entity": "test:scheduler", "detail": f"result: {vr.stdout.strip()}"}])
|
||||
|
||||
# 8. CLOSE: builder transitions through superseding, Scaffolding closes
|
||||
builder.transition(contract_id, "superseding")
|
||||
close_env = builder.close(contract_id, observed_evidence=[
|
||||
{"entity": "scheduler.js", "detail": "implemented in scope"},
|
||||
{"entity": "test:scheduler", "detail": "PASSED"}],
|
||||
scope_files=[str(proj_dir / f) for f in allowed])
|
||||
assert close_env["success"]
|
||||
assert close_env["state"]["contract_quality"]["scope_check"]["verdict"] == "green"
|
||||
|
||||
# 9. FRESH RESUME: a completely fresh builder recovers state
|
||||
fresh = ci.ConstructionInterface(
|
||||
builder="fresh-builder",
|
||||
project_store=setup["store"],
|
||||
project_id="practice-scheduler-js")
|
||||
resumed = fresh.resume(contract_id)
|
||||
assert resumed["success"]
|
||||
st = resumed["state"]
|
||||
assert st["need"]["objective"] == (
|
||||
"create a practice scheduler with create/list/complete")
|
||||
assert st["phase"] == "closed"
|
||||
assert st["scope"] == allowed
|
||||
assert st["obligations"]
|
||||
|
||||
|
||||
def test_builder_cannot_self_certify(setup):
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="rogue", project_store=setup["store"],
|
||||
project_id="practice-scheduler-js")
|
||||
iface.scaffold.init(purpose="test")
|
||||
doc = iface.scaffold.load()
|
||||
iface.scaffold.new_contract(doc, contract_id="ROGUE", need="test",
|
||||
scope=["src/x"])
|
||||
iface.scaffold.save(doc)
|
||||
env = iface.close("ROGUE", observed_evidence=[
|
||||
{"entity": "x", "detail": "done"}])
|
||||
assert not env["success"]
|
||||
assert env["errors"][0]["error"] == ci.Errors.VERIFICATION_FAILED
|
||||
|
||||
|
||||
def test_builder_cannot_broaden_scope(setup):
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="sneaky", project_store=setup["store"],
|
||||
project_id="practice-scheduler-js")
|
||||
iface.scaffold.init(purpose="test")
|
||||
doc = iface.scaffold.load()
|
||||
iface.scaffold.new_contract(doc, contract_id="SC", need="test",
|
||||
scope=["src/allowed.py"])
|
||||
iface.scaffold.save(doc)
|
||||
env = iface.amend_scope("SC", ["src/unauthorized.py"],
|
||||
reason="need more", authority="builder")
|
||||
assert not env["success"]
|
||||
assert env["errors"][0]["error"] == ci.Errors.APPROVAL_REQUIRED
|
||||
|
||||
|
||||
class TestSelfHostingEquivalence:
|
||||
"""§13G: same construction machinery for Rune-internal and external
|
||||
projects. Core semantics must match."""
|
||||
|
||||
def _make_external(self, tmp_path):
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="b", project_store=str(tmp_path / "ext" / "scaffold"),
|
||||
project_id="ext-proj")
|
||||
iface.scaffold.init(purpose="external")
|
||||
return iface
|
||||
|
||||
def _make_internal(self, tmp_path):
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="b", project_store=str(tmp_path / "int" / "scaffold"),
|
||||
project_id="rune")
|
||||
iface.scaffold.init(purpose="internal")
|
||||
return iface
|
||||
|
||||
def test_same_lifecycle_both_modes(self, tmp_path):
|
||||
"""Same lifecycle steps produce same structural semantics."""
|
||||
for iface in (self._make_external(tmp_path),
|
||||
self._make_internal(tmp_path)):
|
||||
doc = iface.scaffold.load()
|
||||
iface.scaffold.new_contract(doc, contract_id="EQ", need="test",
|
||||
scope=["src/x"])
|
||||
iface.scaffold.transition_contract(doc, "EQ", "approved")
|
||||
iface.scaffold.transition_contract(doc, "EQ", "building")
|
||||
iface.scaffold.commit(doc, what="lifecycle")
|
||||
doc2 = iface.scaffold.load()
|
||||
c = iface.scaffold.contract(doc2, "EQ")
|
||||
assert c["phase"] == "building"
|
||||
assert c["identities"]["need"] == "test"
|
||||
|
||||
|
||||
class TestInterruptionResume:
|
||||
"""§13F: real fresh-process resume at each lifecycle phase."""
|
||||
|
||||
@pytest.mark.parametrize("phase", ["approved", "building", "verifying",
|
||||
"closed"])
|
||||
def test_fresh_builder_resume(self, tmp_path, phase):
|
||||
"""A fresh builder process with NO prior context recovers the
|
||||
construction state from the durable store alone."""
|
||||
store = str(tmp_path / "scaffold")
|
||||
s = ci.ConstructionInterface(builder="original",
|
||||
project_store=store, project_id="p")
|
||||
s.scaffold.init(purpose="resume test")
|
||||
doc = s.scaffold.load()
|
||||
s.scaffold.new_contract(doc, contract_id="R1", need="bounded work",
|
||||
scope=["src/x"],
|
||||
verification_obligations=[
|
||||
"verify change works"])
|
||||
s.scaffold.transition_contract(doc, "R1", "approved")
|
||||
s.scaffold.commit(doc, what="approved")
|
||||
if phase in ("building", "verifying", "closed"):
|
||||
s.scaffold.transition_contract(doc, "R1", "building")
|
||||
s.scaffold.commit(doc, what="building")
|
||||
if phase in ("verifying", "closed"):
|
||||
s.scaffold.transition_contract(doc, "R1", "verifying")
|
||||
s.scaffold.commit(doc, what="verifying")
|
||||
if phase == "closed":
|
||||
s.scaffold.close_contract(doc, "R1", observed_evidence=[
|
||||
{"entity": "module:x", "source": "test"}])
|
||||
s.scaffold.commit(doc, what="closed")
|
||||
# FRESH builder: no prior context, only contract id + store path
|
||||
script = textwrap.dedent(f"""\
|
||||
import sys, json
|
||||
sys.path.insert(0, {os.path.join(ROOT, "src")!r})
|
||||
from rune.construction_interface import ConstructionInterface
|
||||
iface = ConstructionInterface(
|
||||
builder="fresh-builder",
|
||||
project_store={store!r},
|
||||
project_id="p")
|
||||
env = iface.resume("R1")
|
||||
st = env["state"]
|
||||
print(json.dumps({{
|
||||
"phase": st["phase"],
|
||||
"need": st["need"].get("objective", ""),
|
||||
"scope": st["scope"],
|
||||
"obligations": len(st["obligations"]),
|
||||
"next_action": st["next_action"],
|
||||
}}))
|
||||
""")
|
||||
r = subprocess.run([sys.executable, "-c", script],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, r.stderr
|
||||
recovered = json.loads(r.stdout)
|
||||
assert recovered["phase"] == phase
|
||||
assert recovered["need"] == "bounded work"
|
||||
assert recovered["scope"] == ["src/x"]
|
||||
assert recovered["obligations"] > 0
|
||||
assert recovered["next_action"]
|
||||
|
||||
def test_resume_does_not_duplicate(self, tmp_path):
|
||||
store = str(tmp_path / "scaffold")
|
||||
s = ci.ConstructionInterface(builder="original",
|
||||
project_store=store, project_id="p")
|
||||
s.scaffold.init(purpose="test")
|
||||
doc = s.scaffold.load()
|
||||
s.scaffold.new_contract(doc, contract_id="R2", need="work",
|
||||
scope=["src/x"])
|
||||
s.scaffold.commit(doc, what="created")
|
||||
# fresh builder loads — no re-creation
|
||||
s2 = ci.ConstructionInterface(builder="fresh",
|
||||
project_store=store, project_id="p")
|
||||
doc2 = s2.scaffold.load()
|
||||
before = len([e for e in doc2["entities"]
|
||||
if e["kind"] == "construction_contract"])
|
||||
s2.scaffold.transition_contract(doc2, "R2", "approved")
|
||||
s2.scaffold.commit(doc2, what="resumed transition")
|
||||
after = len([e for e in s2.scaffold.load()["entities"]
|
||||
if e["kind"] == "construction_contract"])
|
||||
assert before == after
|
||||
@@ -0,0 +1,268 @@
|
||||
"""test_capability_fit.py — SCAFFOLD-TRANCHE-003 §27 test plan (A-K).
|
||||
|
||||
Structural discovery, requirement-fit, gap/resolution surface, composition,
|
||||
acquire-external seam, semantic-fit evidence recording.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import capability_fit as cf # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
|
||||
|
||||
def _doc_with(*caps):
|
||||
"""Build a store doc with capability fixtures.
|
||||
Each cap: (id, provides, consumers, maturity, environment, status_extra)"""
|
||||
doc = ss.new_doc()
|
||||
for c in caps:
|
||||
cid, provides, consumers, maturity = c[0], c[1], c[2], c[3]
|
||||
env = c[4] if len(c) > 4 else {}
|
||||
extra = c[5] if len(c) > 5 else {}
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id=cid,
|
||||
kind="capability",
|
||||
identities={"environment": env},
|
||||
maturity=maturity,
|
||||
evidence_refs=["fixture"],
|
||||
)
|
||||
for p in provides:
|
||||
ss.add_entity(doc, id=p, kind="contract", identities={}, evidence_refs=["fixture"])
|
||||
ss.add_relationship(doc, type="implements", from_id=cid, to_id=p, evidence_refs=["fixture"])
|
||||
for cons in consumers:
|
||||
ss.add_entity(doc, id=cons, kind="module", identities={}, evidence_refs=["fixture"])
|
||||
ss.add_relationship(doc, type="consumes", from_id=cons, to_id=cid, evidence_refs=["fixture"])
|
||||
for k, v in extra.items():
|
||||
e = ss.entity(doc, cid)
|
||||
if k == "superseded_by":
|
||||
e["superseded_by"] = v
|
||||
elif k == "status":
|
||||
e["status"] = v
|
||||
return doc
|
||||
|
||||
|
||||
REQ_STREAM = dict(contracts=["contract:streaming", "contract:bounded_memory"], environment={"os": "linux"})
|
||||
|
||||
|
||||
# ── A. structural candidate discovery ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestDiscovery:
|
||||
def test_finds_capability_kinds_and_providers(self):
|
||||
doc = _doc_with(("capability:parser", ["contract:streaming"], [], "WIRED"))
|
||||
req = cf.CapabilityRequirement("parse streams", **REQ_STREAM)
|
||||
ids = [c["id"] for c in cf.discover_candidates(doc, req)]
|
||||
assert "capability:parser" in ids
|
||||
|
||||
def test_open_world_kind_with_provides_is_discoverable(self):
|
||||
doc = _doc_with()
|
||||
ss.add_entity(doc, id="weird_thing", kind="quantum_gizmo", identities={}, evidence_refs=["f"])
|
||||
ss.add_entity(doc, id="contract:streaming", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="provides", from_id="weird_thing", to_id="contract:streaming", evidence_refs=["f"]
|
||||
)
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
assert "weird_thing" in [c["id"] for c in cf.discover_candidates(doc, req)]
|
||||
|
||||
def test_environment_contradiction_excludes_candidate(self):
|
||||
doc = _doc_with(("capability:winonly", ["contract:streaming"], [], "WIRED", {"os": "windows"}))
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
assert "capability:winonly" not in [c["id"] for c in cf.discover_candidates(doc, req)]
|
||||
|
||||
def test_unknown_environment_is_not_contradiction(self):
|
||||
doc = _doc_with(("capability:anyos", ["contract:streaming"], [], "WIRED", {}))
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
assert "capability:anyos" in [c["id"] for c in cf.discover_candidates(doc, req)]
|
||||
|
||||
|
||||
# ── B/C/D/E. fit levels ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFit:
|
||||
def _cand(self, doc, cid):
|
||||
return next(
|
||||
c for c in cf.discover_candidates(doc, cf.CapabilityRequirement("x", **REQ_STREAM)) if c["id"] == cid
|
||||
)
|
||||
|
||||
def test_exact_fit(self):
|
||||
doc = _doc_with(("capability:p", ["contract:streaming", "contract:bounded_memory"], [], "WIRED"))
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
f = cf.evaluate_fit(req, self._cand(doc, "capability:p"))
|
||||
assert f["fit"] == "EXACT"
|
||||
|
||||
def test_partial_fit(self):
|
||||
doc = _doc_with(("capability:p", ["contract:streaming"], [], "WIRED"))
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
f = cf.evaluate_fit(req, self._cand(doc, "capability:p"))
|
||||
assert f["fit"] == "PARTIAL"
|
||||
assert f["missing_contracts"] == ["contract:bounded_memory"]
|
||||
|
||||
def test_incompatible_candidate(self):
|
||||
doc = _doc_with(("capability:p", ["contract:other"], [], "WIRED"))
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
f = cf.evaluate_fit(req, self._cand(doc, "capability:p"))
|
||||
assert f["fit"] == "INCOMPATIBLE"
|
||||
|
||||
def test_stale_candidate_rejected_as_incompatible(self):
|
||||
doc = _doc_with(
|
||||
("capability:p", ["contract:streaming", "contract:bounded_memory"], [], "WIRED", {}, {"status": "STALE"})
|
||||
)
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
f = cf.evaluate_fit(req, self._cand(doc, "capability:p"))
|
||||
assert f["fit"] == "INCOMPATIBLE"
|
||||
assert "STALE" in f["reason"]
|
||||
|
||||
def test_superseded_candidate_rejected(self):
|
||||
doc = _doc_with(
|
||||
(
|
||||
"capability:old",
|
||||
["contract:streaming", "contract:bounded_memory"],
|
||||
[],
|
||||
"WIRED",
|
||||
{},
|
||||
{"superseded_by": "capability:new"},
|
||||
)
|
||||
)
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
f = cf.evaluate_fit(req, self._cand(doc, "capability:old"))
|
||||
assert f["fit"] == "INCOMPATIBLE"
|
||||
|
||||
def test_unknown_fit_when_no_contracts(self):
|
||||
doc = _doc_with()
|
||||
ss.add_entity(
|
||||
doc, id="capability:opaque", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
cand = next(c for c in cf.discover_candidates(doc, req) if c["id"] == "capability:opaque")
|
||||
f = cf.evaluate_fit(req, cand)
|
||||
assert f["fit"] == "UNKNOWN"
|
||||
|
||||
|
||||
# ── F/G/H/I/J. resolution surface ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolution:
|
||||
def test_exact_fit_reuses(self):
|
||||
doc = _doc_with(("capability:p", ["contract:streaming", "contract:bounded_memory"], [], "WIRED"))
|
||||
out = cf.resolve(doc, cf.CapabilityRequirement("x", **REQ_STREAM))
|
||||
assert out["resolution"] == "REUSE"
|
||||
assert out["selected"] == "capability:p"
|
||||
|
||||
def test_satisfied_when_context_already_consumes(self):
|
||||
doc = _doc_with(("capability:p", ["contract:streaming", "contract:bounded_memory"], ["module:mytask"], "WIRED"))
|
||||
req = cf.CapabilityRequirement("x", already_consumed_by="module:mytask", **REQ_STREAM)
|
||||
out = cf.resolve(doc, req)
|
||||
assert out["resolution"] == "SATISFIED"
|
||||
|
||||
def test_clean_partial_adaptation_selected(self):
|
||||
doc = _doc_with(("capability:p", ["contract:streaming"], [], "WIRED"))
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
out = cf.resolve(doc, req)
|
||||
assert out["resolution"] == "ADAPT_EXISTING"
|
||||
assert out["selected"] == "capability:p"
|
||||
assert out["obligations"]["regression_proof"] is True
|
||||
|
||||
def test_brittle_coupling_forces_build_new_with_rationale(self):
|
||||
# bounded_memory is load-bearing: another module consumes it
|
||||
doc = _doc_with(
|
||||
("capability:p", ["contract:streaming"], [], "WIRED"),
|
||||
("contract:bounded_memory", [], [], "WIRED"),
|
||||
("module:hot_consumer", [], [], "WIRED"),
|
||||
)
|
||||
ss.add_relationship(
|
||||
doc, type="consumes", from_id="module:hot_consumer", to_id="contract:bounded_memory", evidence_refs=["f"]
|
||||
)
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
out = cf.resolve(doc, req)
|
||||
assert out["resolution"] == "BUILD_NEW"
|
||||
assert "brittle" in out["rationale"].lower() or "load-bearing" in out.get("brittle", "")
|
||||
assert out["obligations"]["new_behavior_proof"] is True
|
||||
|
||||
def test_two_capability_composition(self):
|
||||
doc = _doc_with(
|
||||
("capability:a", ["contract:streaming"], [], "WIRED"),
|
||||
("capability:b", ["contract:bounded_memory"], [], "WIRED"),
|
||||
)
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
out = cf.resolve(doc, req)
|
||||
assert out["resolution"] == "COMPOSE"
|
||||
assert set(out["components"]) == {"capability:a", "capability:b"}
|
||||
assert out["permanence"] == "temporary (user-controlled)"
|
||||
|
||||
def test_no_candidates_no_external_evidence_insufficient(self):
|
||||
doc = _doc_with()
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
out = cf.resolve(doc, req)
|
||||
assert out["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert "BUILD_NEW not silently chosen" in out["rationale"]
|
||||
|
||||
def test_no_candidates_with_external_evidence_acquires(self):
|
||||
doc = _doc_with()
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
out = cf.resolve(
|
||||
doc,
|
||||
req,
|
||||
external_evidence=[{"tool": "ripgrep", "evidence": "mature streaming regex engine", "license": "MIT"}],
|
||||
)
|
||||
assert out["resolution"] == "ACQUIRE_EXTERNAL"
|
||||
assert out["external"][0]["tool"] == "ripgrep"
|
||||
|
||||
def test_all_incompatible_no_evidence_insufficient_not_build(self):
|
||||
doc = _doc_with(("capability:p", ["contract:other"], [], "WIRED"))
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
out = cf.resolve(doc, req)
|
||||
assert out["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
def test_adapt_brittle_with_external_prefers_acquire(self):
|
||||
doc = _doc_with(
|
||||
("capability:p", ["contract:streaming"], [], "WIRED"),
|
||||
("contract:bounded_memory", [], [], "WIRED"),
|
||||
("module:hot_consumer", [], [], "WIRED"),
|
||||
)
|
||||
ss.add_relationship(
|
||||
doc, type="consumes", from_id="module:hot_consumer", to_id="contract:bounded_memory", evidence_refs=["f"]
|
||||
)
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
out = cf.resolve(doc, req, external_evidence=[{"tool": "libstream", "license": "Apache-2.0"}])
|
||||
assert out["resolution"] == "ACQUIRE_EXTERNAL"
|
||||
|
||||
def test_unknown_only_candidates_insufficient(self):
|
||||
doc = _doc_with()
|
||||
ss.add_entity(
|
||||
doc, id="capability:opaque", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
out = cf.resolve(doc, req)
|
||||
assert out["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
# ── K. semantic-fit evidence recording ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestSemanticSeam:
|
||||
def test_record_requires_provider_and_model(self):
|
||||
with pytest.raises(ValueError):
|
||||
cf.record_semantic_fit("", "", "why", [], 0.5, "prose")
|
||||
|
||||
def test_record_validates_confidence(self):
|
||||
with pytest.raises(ValueError):
|
||||
cf.record_semantic_fit("p", "m", "why", [], 1.5, "prose")
|
||||
|
||||
def test_record_carries_full_provenance(self):
|
||||
r = cf.record_semantic_fit("local-ollama", "llama3", "name ambiguity", ["evidence-1"], 0.6, "probably fits")
|
||||
assert r["provider"] == "local-ollama" and r["model"] == "llama3"
|
||||
assert r["confidence"] == 0.6
|
||||
assert r["kind"] == "semantic_fit"
|
||||
|
||||
def test_decision_carries_semantic_records_not_authority(self):
|
||||
doc = _doc_with(("capability:opaque", [], [], "WIRED"))
|
||||
req = cf.CapabilityRequirement("x", **REQ_STREAM)
|
||||
sem = [cf.record_semantic_fit("p", "m", "no contracts to check", ["evidence"], 0.4, "may fit")]
|
||||
out = cf.resolve(doc, req, semantic_fits=sem)
|
||||
assert out["resolution"] == "INSUFFICIENT_EVIDENCE" # prose ≠ authority
|
||||
assert out["semantic_fits"][0]["provider"] == "p"
|
||||
@@ -0,0 +1,303 @@
|
||||
"""test_ci_event_wait.py — T16 §16C/§16D/§16E: event-driven CI
|
||||
qualification wait over the EXISTING W/R bridge.
|
||||
|
||||
Same disposable harness as test_event_wait.py (real daemon subprocess on
|
||||
an isolated WR_ROOT + ephemeral port; Daemon fixture imported — 16W rule:
|
||||
no duplicated test helpers).
|
||||
|
||||
Proofs:
|
||||
16C event wake — CI-completion event (provider=gitea,
|
||||
event_type=ci_completed, correlated by sha) resumes
|
||||
the waiting controller; the EVENT payload is NOT
|
||||
truth — the verdict is re-read from canonical Gitea
|
||||
authority (monkeypatched here; production = deploy_
|
||||
qualify.py).
|
||||
16C event≠truth — event claims success, authority says failure →
|
||||
deployment refused (payload never decides).
|
||||
16D no floor — event → resume latency is event-driven (sub-second),
|
||||
not a fixed sleep floor.
|
||||
16D lost event — event suppressed → watchdog expiry → ONE-SHOT
|
||||
authority query → truthful recovery (or truthful
|
||||
failure when authority has no verdict). Watchdog is
|
||||
the failure detector, never the scheduler.
|
||||
16D re-arm — watchdog expiry while CI runs are non-terminal in
|
||||
Gitea → re-arm, not failure; a later event resumes.
|
||||
16E one primitive— builder and gitea waits share the SAME bridge
|
||||
machinery; identities select the matching events
|
||||
(a gitea event never resumes a builder wait and
|
||||
vice versa).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
from tests.test_event_wait import Daemon # noqa: E402 (shared fixture)
|
||||
|
||||
|
||||
def _load_ci_wait_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ci_event_wait", str(REPO / "scripts" / "ci_event_wait.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
CI_SHA = "e" * 40
|
||||
|
||||
|
||||
def _post_when_registered(wr_daemon: Daemon, op: str, payload: dict,
|
||||
timeout: float = 10.0):
|
||||
"""Thread body: wait for the wait record to exist, then post the wake
|
||||
event. The event POST is best-effort (tests assert on outcomes)."""
|
||||
|
||||
def go():
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if ew.operation_wait(op) is not None:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
wr_daemon.post_event(payload) # loud: a failed wake must fail the test
|
||||
|
||||
t = threading.Thread(target=go, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def test_16c_event_wake_resumes_and_authority_decides(wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
payload = {"operation_id": f"ci-{CI_SHA}", "provider": "gitea",
|
||||
"event_type": "ci_completed", "sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()}
|
||||
_post_when_registered(wr_daemon, f"ci-{CI_SHA}", payload)
|
||||
t0 = time.monotonic()
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=25, daemon_url=wr_daemon.url())
|
||||
wall = time.monotonic() - t0
|
||||
# rc==0 alone is NOT proof: watchdog recovery also returns 0. The wake
|
||||
# must actually have driven the resume (fast), not the watchdog.
|
||||
assert rc == 0
|
||||
assert wall < 15, f"wake-resume took {wall:.1f}s — event never arrived"
|
||||
rec = ew.operation_wait(f"ci-{CI_SHA}")
|
||||
assert rec is not None and rec["provider"] == "gitea"
|
||||
assert rec["wait_kind"] == "PROCESS_EVENT"
|
||||
|
||||
|
||||
def test_16c_event_payload_is_never_truth(wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
# the event says success (exit_code 0); canonical authority says the
|
||||
# CI is RED — the payload must never decide the verdict
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "failure")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
payload = {"operation_id": f"ci-{CI_SHA}", "provider": "gitea",
|
||||
"event_type": "ci_completed", "sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()}
|
||||
_post_when_registered(wr_daemon, f"ci-{CI_SHA}", payload)
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=25, daemon_url=wr_daemon.url())
|
||||
assert rc == 1 # refused, despite the wake saying 0
|
||||
|
||||
|
||||
def test_16d_event_resume_has_no_sleep_floor(wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
payload = {"operation_id": f"ci-{CI_SHA}", "provider": "gitea",
|
||||
"event_type": "ci_completed", "sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()}
|
||||
_post_when_registered(wr_daemon, f"ci-{CI_SHA}", payload)
|
||||
t0 = time.monotonic()
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=25, daemon_url=wr_daemon.url())
|
||||
wall = time.monotonic() - t0
|
||||
# a would-be 30s polling floor is NOT waited: registration + event +
|
||||
# verdict must be seconds, not the old sleep-30 loop quantum
|
||||
assert rc == 0
|
||||
assert wall < 15, f"event-driven wait took {wall:.1f}s — floor crept back"
|
||||
|
||||
|
||||
def test_16d_lost_event_watchdog_recovers_from_authority(
|
||||
wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
# NO event is ever posted (suppressed/lost); authority IS resolvable
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=4, daemon_url=wr_daemon.url())
|
||||
assert rc == 0
|
||||
# truthful recovery, not invented success: the record shows the
|
||||
# watchdog (failure detector) path, and the verdict came from authority
|
||||
rec = ew.operation_wait(f"ci-{CI_SHA}")
|
||||
assert rec is not None
|
||||
|
||||
|
||||
def test_16d_lost_event_with_no_verdict_fails_truthfully(
|
||||
wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "pending")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=4, daemon_url=wr_daemon.url())
|
||||
assert rc == 1 # deployment refused — timeout is failure detection
|
||||
|
||||
|
||||
def test_16d_ci_alive_rearm_then_event(wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
# first watchdog expiry (t=3s): CI runs still non-terminal → re-arm,
|
||||
# not fail; the event arrives only AFTER that expiry (t≥4s)
|
||||
alive = {"n": 0}
|
||||
|
||||
def flaky_alive():
|
||||
alive["n"] += 1
|
||||
return alive["n"] <= 1 # alive on first expiry, gone after event
|
||||
|
||||
monkeypatch.setattr(mod, "ci_alive", flaky_alive)
|
||||
|
||||
def post_late():
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if ew.operation_wait(f"ci-{CI_SHA}") is not None:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
time.sleep(4.0) # strictly after the first watchdog expiry (3s)
|
||||
wr_daemon.post_event({"operation_id": f"ci-{CI_SHA}",
|
||||
"provider": "gitea",
|
||||
"event_type": "ci_completed",
|
||||
"sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()})
|
||||
|
||||
threading.Thread(target=post_late, daemon=True).start()
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=15, watchdog_secs=3,
|
||||
daemon_url=wr_daemon.url())
|
||||
assert rc == 0
|
||||
assert alive["n"] >= 1 # the re-arm actually happened
|
||||
|
||||
|
||||
def test_16e_one_bridge_two_adapters_identity_selects(wr_daemon):
|
||||
"""16E: builder and gitea waits share the same primitive; only the
|
||||
identity fields differ, and identity selects the matching event."""
|
||||
# builder-style wait
|
||||
b = ew.register_process_wait("op-builder-1", watchdog_secs=60)
|
||||
# gitea-style wait
|
||||
g = ew.register_process_wait(f"ci-{CI_SHA}", provider="gitea",
|
||||
event_type="ci_completed", sha=CI_SHA,
|
||||
watchdog_secs=60)
|
||||
assert b["provider"] == "builder" and g["provider"] == "gitea"
|
||||
# a gitea CI event must NOT resume the builder wait...
|
||||
wr_daemon.post_event({"operation_id": f"ci-{CI_SHA}", "provider": "gitea",
|
||||
"event_type": "ci_completed", "sha": CI_SHA,
|
||||
"exit_code": 0})
|
||||
brec = ew.operation_wait("op-builder-1")
|
||||
grec = ew.operation_wait(f"ci-{CI_SHA}")
|
||||
assert brec["status"] == "WAITING_EXTERNAL"
|
||||
assert grec["status"] in ("READY_TO_RESUME", "RESUME_UNVERIFIED",
|
||||
"COMPLETED")
|
||||
# ...and a builder event must not resume a (fresh) gitea wait
|
||||
g2 = ew.register_process_wait(f"ci-{'f' * 40}", provider="gitea",
|
||||
event_type="ci_completed", sha="f" * 40,
|
||||
watchdog_secs=60)
|
||||
wr_daemon.post_event({"operation_id": "op-builder-1", "exit_code": 0})
|
||||
assert ew.operation_wait(f"ci-{'f' * 40}")["status"] == "WAITING_EXTERNAL"
|
||||
assert ew.operation_wait("op-builder-1")["status"] in (
|
||||
"READY_TO_RESUME", "RESUME_UNVERIFIED", "COMPLETED")
|
||||
|
||||
|
||||
def test_16c_event_replay_before_registration(wr_daemon, monkeypatch):
|
||||
"""§16C live finding (serialized runner): the completion event lands
|
||||
BEFORE the deploy job registers. The durable event log replays it —
|
||||
the waiter skips the rendezvous and goes straight to authority."""
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
wr_daemon.post_event({"operation_id": f"ci-{CI_SHA}",
|
||||
"provider": "gitea", "event_type": "ci_completed",
|
||||
"sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()})
|
||||
t0 = time.monotonic()
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=60, watchdog_secs=300,
|
||||
daemon_url=wr_daemon.url())
|
||||
wall = time.monotonic() - t0
|
||||
assert rc == 0
|
||||
assert wall < 10, f"replayed wake took {wall:.1f}s — replay path dead"
|
||||
assert ew.find_matching_event("gitea", "ci_completed",
|
||||
f"ci-{CI_SHA}") is not None
|
||||
|
||||
|
||||
def test_16c_replay_does_not_match_other_identities(wr_daemon):
|
||||
wr_daemon.post_event({"operation_id": f"ci-{CI_SHA}",
|
||||
"provider": "gitea", "event_type": "ci_completed",
|
||||
"sha": CI_SHA, "exit_code": 0})
|
||||
assert ew.find_matching_event("gitea", "ci_completed",
|
||||
"ci-other") is None
|
||||
assert ew.find_matching_event("builder", "builder_exit",
|
||||
f"ci-{CI_SHA}") is None
|
||||
|
||||
|
||||
def test_17c_fifo_mode_is_cross_user(wr_daemon):
|
||||
"""T17 17C ROOT-CAUSE REGRESSION GUARD: the T16 chmod(0o666) was lost
|
||||
in a rebase; FIFOs landed 644, the daemon (different user) got EACCES
|
||||
on the resume write, and every production waiter slept to its
|
||||
deadline. Mode must be 666 regardless of umask/creating user."""
|
||||
import os as _os
|
||||
import stat as _stat
|
||||
fd = ew.open_fifo_reader("ci-mode-guard-op")
|
||||
try:
|
||||
mode = _os.stat(ew.fifo_path("ci-mode-guard-op")).st_mode
|
||||
assert mode & 0o666 == 0o666, (
|
||||
f"fifo mode {oct(mode & 0o777)} — resume writer from another "
|
||||
f"user will get EACCES (17C root-cause regression)")
|
||||
finally:
|
||||
_os.close(fd)
|
||||
|
||||
|
||||
def test_17c_cross_user_resume_delivery(wr_daemon):
|
||||
"""Full cross-user delivery: the WAITER side (fifo + registration)
|
||||
runs as the invoking user; the resume WRITE runs through the real
|
||||
daemon (as its own user in production). After the mode fix the
|
||||
daemon's writer must succeed (rc=0, wait COMPLETED), not rc=1."""
|
||||
import os as _os
|
||||
op = f"ci-crossuser-{int(time.time())}"
|
||||
fd = ew.open_fifo_reader(op)
|
||||
rec = ew.register_process_wait(op, provider="gitea",
|
||||
event_type="ci_completed",
|
||||
sha="crossuser", watchdog_secs=60)
|
||||
try:
|
||||
import threading
|
||||
done = {}
|
||||
|
||||
def waiter():
|
||||
done["r"] = ew.await_resume_event(op, watchdog_secs=15,
|
||||
fifo_fd=fd)
|
||||
|
||||
t = threading.Thread(target=waiter, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.3)
|
||||
wr_daemon.post_event({"operation_id": op, "provider": "gitea",
|
||||
"event_type": "ci_completed",
|
||||
"sha": "crossuser", "exit_code": 0,
|
||||
"completed_at": time.time()})
|
||||
t.join(timeout=20)
|
||||
assert done.get("r", {}).get("kind") == "event", (
|
||||
f"resume delivery failed again: {done} — 17C root-cause "
|
||||
f"regression")
|
||||
# the durable record must show the writer exited rc=0
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
r = ew.operation_wait(op) or {}
|
||||
if r.get("status") == "COMPLETED":
|
||||
break
|
||||
time.sleep(0.2)
|
||||
assert r.get("status") == "COMPLETED", (
|
||||
f"wait status {r.get('status')} — resume writer did not "
|
||||
f"deliver cleanly (expect COMPLETED, not RESUME_UNVERIFIED)")
|
||||
finally:
|
||||
_os.close(fd)
|
||||
@@ -0,0 +1,375 @@
|
||||
"""test_construction_decision.py — SCAFFOLD-TRANCHE-004 §29 test plan (A-M)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import capability_fit as cf # noqa: E402
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune import toolchain as tc # noqa: E402
|
||||
|
||||
|
||||
# ── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _spine_doc():
|
||||
"""Rune-Spine-like authority with an existing export capability."""
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(
|
||||
doc, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["fixture"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["fixture"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["fixture"]
|
||||
)
|
||||
ss.add_entity(doc, id="module:cli", kind="module", identities={}, evidence_refs=["fixture"])
|
||||
ss.add_relationship(
|
||||
doc, type="consumes", from_id="module:cli", to_id="capability:exporter", evidence_refs=["fixture"]
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
def _need(**kw):
|
||||
defaults = dict(
|
||||
need_id="need-001",
|
||||
objective="persistent project export",
|
||||
origin="user",
|
||||
project="rune",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=[],
|
||||
evidence_refs=["user-request"],
|
||||
approval_context="covered by SCAFFOLD-TRANCHE-004",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return cd.Need(**defaults)
|
||||
|
||||
|
||||
# ── A. Need → CapabilityRequirement normalization ───────────────────────────
|
||||
|
||||
|
||||
def test_a_normalization():
|
||||
req, missing = cd.normalize_need(_need())
|
||||
assert req.objective == "persistent project export"
|
||||
assert "contract:csv_export" in req.contracts
|
||||
assert req.props["source_need"] == "need-001"
|
||||
assert req.props["task_ref"] == ""
|
||||
|
||||
|
||||
def test_a_normalization_preserves_provenance_and_flags_gaps():
|
||||
need = _need(evidence_refs=[], constraints={})
|
||||
req, missing = cd.normalize_need(need)
|
||||
assert req.props["source_need"] == "need-001"
|
||||
assert "evidence_refs" in missing # recorded, not invented
|
||||
|
||||
|
||||
# ── B. Need references Task/Intent without duplicating authority ───────────
|
||||
|
||||
|
||||
def test_b_need_references_task_authority():
|
||||
need = _need(task_ref="intent:EXPR-42")
|
||||
d = need.to_dict()
|
||||
assert d["task_ref"] == "intent:EXPR-42"
|
||||
# the Need carries a REFERENCE — no task status/state fields of its own
|
||||
assert "status" not in d and "state" not in d and "tasks" not in d
|
||||
|
||||
|
||||
# ── C. structural discovery invoked automatically ───────────────────────────
|
||||
|
||||
|
||||
def test_c_live_discovery():
|
||||
trace = cd.make_decision(_need(), authorities={"rune_spine": _spine_doc()})
|
||||
assert any(c["id"] == "capability:exporter" for c in trace["candidates"])
|
||||
assert trace["candidates"][0]["authority"] == "rune_spine"
|
||||
|
||||
|
||||
# ── D. exact REUSE path ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_d_reuse_path():
|
||||
trace = cd.make_decision(_need(), authorities={"rune_spine": _spine_doc()})
|
||||
assert trace["resolution"] == "REUSE"
|
||||
assert trace["research"]["route"] == "NONE"
|
||||
|
||||
|
||||
# ── E. ADAPT_EXISTING path (with auto regression obligations) ───────────────
|
||||
|
||||
|
||||
def test_e_adapt_path_auto_regression():
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(
|
||||
doc, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="module:cli", kind="module", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(doc, type="consumes", from_id="module:cli", to_id="capability:exporter", evidence_refs=["f"])
|
||||
need = _need(evidence_refs=["x"])
|
||||
need.constraints["contracts"] = ["contract:csv_export", "contract:encrypted_export"]
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": doc})
|
||||
assert trace["resolution"] == "ADAPT_EXISTING"
|
||||
# BUILD-BUT-DON'T-BREAK obligations generated AUTOMATICALLY (§9):
|
||||
reg = " ".join(trace["obligations"]["regression"])
|
||||
assert "preserve existing behavior of capability:exporter" in reg
|
||||
assert "module:cli" in reg
|
||||
|
||||
|
||||
# ── F. COMPOSE path ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_f_compose_path():
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(doc, id="capability:a", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
ss.add_entity(doc, id="capability:b", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
for c in ("contract:streaming", "contract:bounded_memory"):
|
||||
ss.add_entity(doc, id=c, kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(doc, type="implements", from_id="capability:a", to_id="contract:streaming", evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:b", to_id="contract:bounded_memory", evidence_refs=["f"]
|
||||
)
|
||||
need = _need(
|
||||
objective="stream with bounded memory",
|
||||
constraints={"contracts": ["contract:streaming", "contract:bounded_memory"]},
|
||||
evidence_refs=["x"],
|
||||
)
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": doc})
|
||||
assert trace["resolution"] == "COMPOSE"
|
||||
assert set(trace["obligations"]["composition"][0]["components"]) == {"capability:a", "capability:b"}
|
||||
assert "temporary" in trace["obligations"]["composition"][0]["permanence"]
|
||||
|
||||
|
||||
# ── G. ACQUIRE_EXTERNAL routes to research seam ─────────────────────────────
|
||||
|
||||
|
||||
def test_g_acquire_routes_to_research():
|
||||
trace = cd.make_decision(
|
||||
_need(objective="quantum error correction", constraints={"contracts": ["contract:qec"]}, evidence_refs=["x"]),
|
||||
authorities={"rune_spine": ss.new_doc()},
|
||||
)
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE" # no candidates at all
|
||||
trace2 = cd.make_decision(
|
||||
_need(objective="quantum error correction", constraints={"contracts": ["contract:qec"]}, evidence_refs=["x"]),
|
||||
authorities={"rune_spine": _spine_doc()},
|
||||
)
|
||||
assert trace2["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
# external evidence → ACQUIRE + full research route recorded (seam)
|
||||
doc = _spine_doc()
|
||||
ss.add_entity(doc, id="capability:x", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
trace3 = cd.make_decision(
|
||||
_need(objective="qec", constraints={"contracts": ["contract:qec"]}, evidence_refs=["x"]),
|
||||
authorities={"rune_spine": doc},
|
||||
)
|
||||
assert trace3["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert trace3["research"]["status"] == "RECORDED_NOT_EXECUTED" or trace3["research"]["route"] in (
|
||||
"NONE",
|
||||
"TARGETED_EVIDENCE_PROBE",
|
||||
)
|
||||
|
||||
|
||||
def test_g_acquire_with_external_evidence():
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(doc, id="capability:p", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
# candidate provides nothing required → all incompatible
|
||||
res = cf.decide(
|
||||
doc,
|
||||
cd.normalize_need(_need())[0],
|
||||
[
|
||||
cf.evaluate_fit(
|
||||
cd.normalize_need(_need())[0],
|
||||
{
|
||||
"id": "capability:p",
|
||||
"kind": "capability",
|
||||
"status": "CURRENT",
|
||||
"superseded_by": None,
|
||||
"provides": ["contract:other"],
|
||||
"consumers": [],
|
||||
"maturity": "WIRED",
|
||||
"evidence_refs": [],
|
||||
"identities": {},
|
||||
},
|
||||
)
|
||||
],
|
||||
external_evidence=[{"tool": "libcsv", "license": "MIT"}],
|
||||
)
|
||||
assert res["resolution"] == "ACQUIRE_EXTERNAL"
|
||||
|
||||
|
||||
# ── H/I. INSUFFICIENT_EVIDENCE never becomes BUILD_NEW ──────────────────────
|
||||
|
||||
|
||||
def test_h_insufficient_never_build_new():
|
||||
trace = cd.make_decision(
|
||||
_need(objective="impossible thing", constraints={"contracts": ["contract:nope"]}, evidence_refs=["x"]),
|
||||
authorities={"rune_spine": _spine_doc()},
|
||||
)
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def test_i_build_new_requires_positive_evidence_everywhere():
|
||||
doc1 = _spine_doc() # has partials → ADAPT/COMPOSE territory
|
||||
doc2 = ss.new_doc() # nothing at all → INSUFFICIENT
|
||||
need = _need(
|
||||
objective="odd requirement",
|
||||
constraints={"contracts": ["contract:csv_export", "contract:never_seen"]},
|
||||
evidence_refs=["x"],
|
||||
)
|
||||
t1 = cd.make_decision(need, authorities={"rune_spine": doc1})
|
||||
assert t1["resolution"] != "BUILD_NEW" # csv_export partial exists → adapt
|
||||
t2 = cd.make_decision(need, authorities={"rune_spine": doc1, "project_scaffold": doc2})
|
||||
assert t2["resolution"] == "ADAPT_EXISTING" # merge: concrete beats insufficient
|
||||
# BUILD_NEW only via brittle-coupling positive evidence (unit-covered in
|
||||
# test_capability_fit) — merge never upgrades insufficient to build
|
||||
|
||||
|
||||
def test_i_normalization_gap_blocks_build_new():
|
||||
doc = ss.new_doc()
|
||||
# a partial fit exists; normalization is incomplete
|
||||
ss.add_entity(doc, id="capability:p", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:p", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:missing_half", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:p", to_id="contract:missing_half", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:unverifiable", kind="contract", identities={}, evidence_refs=["f"])
|
||||
need = _need(
|
||||
objective="x", constraints={"contracts": ["contract:unverifiable"]}, evidence_refs=[]
|
||||
) # no evidence refs → normalization gap
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": doc})
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert "normalization gaps recorded, not invented" in trace["rationale"]
|
||||
|
||||
|
||||
# ── J/K. toolchain decision enters the trace ────────────────────────────────
|
||||
|
||||
|
||||
def test_j_toolchain_in_trace():
|
||||
trace = cd.make_decision(
|
||||
_need(), authorities={"rune_spine": _spine_doc()}, project_toolchain={"existing_language": "python"}
|
||||
)
|
||||
assert trace["toolchain"]["selected_language"] == "python"
|
||||
assert trace["toolchain"]["resolution"] == "KEEP_EXISTING"
|
||||
|
||||
|
||||
def test_k_material_toolchain_change_marks_approval():
|
||||
trace = cd.make_decision(
|
||||
_need(),
|
||||
authorities={"rune_spine": _spine_doc()},
|
||||
toolchain_constraints=tc.ToolchainConstraints(existing_language="python"),
|
||||
)
|
||||
# simulate a requested change via direct resolver check (the live path
|
||||
# records the toolchain decision; a change request surfaces approval)
|
||||
d = tc.resolve_toolchain(tc.ToolchainConstraints(existing_language="python"), requested_language="rust")
|
||||
assert d.user_approval_required is True
|
||||
# and the approval gate honors it:
|
||||
gate = cd.needs_user_approval("REUSE", d, [], _need(approval_context=""))
|
||||
assert gate["required"] is True
|
||||
assert "toolchain change" in gate["reasons"][0]
|
||||
|
||||
|
||||
# ── L. approved bounded maintenance does not repeatedly ask ─────────────────
|
||||
|
||||
|
||||
def test_l_bounded_work_inside_approved_contract_does_not_reask():
|
||||
gate = cd.needs_user_approval(
|
||||
"REUSE",
|
||||
tc.resolve_toolchain(tc.ToolchainConstraints(existing_language="python")),
|
||||
[],
|
||||
_need(approval_context="covered by SCAFFOLD-TRANCHE-004"),
|
||||
)
|
||||
assert gate["required"] is False
|
||||
assert gate["covered_by"] == "covered by SCAFFOLD-TRANCHE-004"
|
||||
|
||||
|
||||
# ── M. fresh-runtime decision trace recovery ────────────────────────────────
|
||||
|
||||
|
||||
def test_m_fresh_runtime_trace_recovery(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "proj")
|
||||
doc = s.init(purpose="test")
|
||||
trace = cd.make_decision(
|
||||
_need(), authorities={"rune_spine": _spine_doc()}, project_toolchain={"existing_language": "python"}
|
||||
)
|
||||
cd.generate_contract(s, doc, trace, contract_id="LIVE-1", scope=["src/export"])
|
||||
s.transition_contract(doc, "LIVE-1", "approved")
|
||||
s.save(doc)
|
||||
# fresh interpreter, no chat transcript
|
||||
script = (
|
||||
"import sys, json;"
|
||||
"sys.path.insert(0, %r);"
|
||||
"from rune import scaffold;"
|
||||
"s = scaffold.Scaffold(%r, 'proj');"
|
||||
"doc = s.load();"
|
||||
"c = s.contract(doc, 'LIVE-1');"
|
||||
"t = c['ext']['decision_trace'];"
|
||||
"print(json.dumps({'need': t['need']['objective'],"
|
||||
"'resolution': t['resolution'],"
|
||||
"'rationale': t['rationale'][:40],"
|
||||
"'toolchain': t['toolchain']['selected_language'],"
|
||||
"'approval_required': t['approval']['required'],"
|
||||
"'contract_phase': c['phase'],"
|
||||
"'candidates': [x['id'] for x in t['candidates']]}))"
|
||||
) % (os.path.join(os.path.dirname(__file__), "..", "src"), str(tmp_path / ".rune" / "scaffold"))
|
||||
r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, r.stderr
|
||||
rec = json.loads(r.stdout.strip())
|
||||
assert rec["need"] == "persistent project export"
|
||||
assert rec["resolution"] == "REUSE"
|
||||
assert rec["toolchain"] == "python"
|
||||
assert rec["approval_required"] is False
|
||||
assert rec["contract_phase"] == "approved"
|
||||
assert "capability:exporter" in rec["candidates"]
|
||||
|
||||
|
||||
# ── contract generation populates fields from the trace ─────────────────────
|
||||
|
||||
|
||||
def test_generated_contract_fields_from_trace(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "proj")
|
||||
doc = s.init(purpose="test")
|
||||
trace = cd.make_decision(
|
||||
_need(
|
||||
objective="add encrypted export",
|
||||
constraints={"contracts": ["contract:csv_export", "contract:encrypted_export"]},
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by SCAFFOLD-TRANCHE-004",
|
||||
),
|
||||
authorities={"rune_spine": _spine_doc()},
|
||||
project_toolchain={"existing_language": "python"},
|
||||
)
|
||||
cd.generate_contract(s, doc, trace, contract_id="GEN-1", scope=["src/export"])
|
||||
c = s.contract(doc, "GEN-1")
|
||||
assert c["identities"]["resolution"] == "ADAPT_EXISTING"
|
||||
assert c["identities"]["toolchain"]["selected_language"] == "python"
|
||||
assert "capability:exporter" in c["identities"]["affected_entities"]
|
||||
assert c["identities"]["regression_obligations"] # auto BUILD-BUT-DON'T-BREAK
|
||||
assert c["ext"]["decision_trace"]["need"]["need_id"] == "need-001"
|
||||
|
||||
|
||||
# ── failure behavior (§28) ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_stale_candidate_rejected_in_live_path():
|
||||
doc = _spine_doc()
|
||||
e = ss.entity(doc, "capability:exporter")
|
||||
e["status"] = "STALE"
|
||||
trace = cd.make_decision(_need(), authorities={"rune_spine": doc})
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def test_merge_build_new_never_upgrades_from_insufficient():
|
||||
build = {"resolution": "BUILD_NEW", "rationale": "brittle"}
|
||||
insuff = {"resolution": "INSUFFICIENT_EVIDENCE", "rationale": "weak"}
|
||||
merged = cd.merge_resolutions([build, insuff])
|
||||
assert merged["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""SCAFFOLD-TRANCHE-0012 §12E: proportional depth held-out matrix."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune.construction_depth import select_depth # noqa: E402
|
||||
|
||||
|
||||
def test_typo_light():
|
||||
d = select_depth("LOCAL_CHANGE", objective="fix typo",
|
||||
affected_surface=["src/x.py"])
|
||||
assert d.level == "LIGHT"
|
||||
assert "project bootstrap" in " ".join(d.bypassed)
|
||||
|
||||
|
||||
def test_small_bounded_parser_fix_light():
|
||||
d = select_depth("LOCAL_CHANGE", objective="fix parser edge case",
|
||||
affected_surface=["src/parser.py"])
|
||||
assert d.level == "LIGHT"
|
||||
|
||||
|
||||
def test_schema_migration_standard_escalates():
|
||||
d = select_depth("EXISTING_PROJECT_CHANGE", has_migration=True)
|
||||
assert d.level in ("STANDARD", "FULL")
|
||||
assert "migration proof" in " ".join(d.ceremony)
|
||||
|
||||
|
||||
def test_breaking_api_full():
|
||||
d = select_depth("EXISTING_PROJECT_CHANGE", breaking=True,
|
||||
has_migration=True)
|
||||
assert d.level == "FULL"
|
||||
|
||||
|
||||
def test_ui_user_locked_retains_obligation_at_light():
|
||||
d = select_depth("LOCAL_CHANGE", user_locked_layout=True)
|
||||
assert d.level == "LIGHT"
|
||||
assert "layout obligation check" in " ".join(d.ceremony)
|
||||
|
||||
|
||||
def test_new_skill_standard():
|
||||
d = select_depth("EXISTING_PROJECT_CHANGE")
|
||||
assert d.level == "STANDARD"
|
||||
|
||||
|
||||
def test_new_standalone_project_full():
|
||||
d = select_depth("PROJECT_BUILD")
|
||||
assert d.level == "FULL"
|
||||
assert "project bootstrap" in " ".join(d.ceremony)
|
||||
|
||||
|
||||
def test_ambiguous_unresolved_not_light():
|
||||
"""§13C: UNKNOWN means construction hasn't started — not LIGHT."""
|
||||
d = select_depth("UNKNOWN")
|
||||
assert d.level == "UNRESOLVED"
|
||||
assert "probe" in " ".join(d.ceremony)
|
||||
|
||||
|
||||
def test_non_construction_no_construction_depth():
|
||||
"""§13C: NOT_CONSTRUCTION has NO construction depth — not LIGHT."""
|
||||
d = select_depth("NOT_CONSTRUCTION")
|
||||
assert d.level == "NONE"
|
||||
assert d.bypassed == ["all"]
|
||||
|
||||
|
||||
def test_wide_surface_escalates_to_full():
|
||||
d = select_depth("EXISTING_PROJECT_CHANGE",
|
||||
affected_surface=[f"f{i}.py" for i in range(8)])
|
||||
assert d.level == "FULL"
|
||||
|
||||
|
||||
def test_high_uncertainty_probe_first():
|
||||
d = select_depth("EXISTING_PROJECT_CHANGE", uncertainty="high")
|
||||
assert d.ceremony[0] == "evidence probe"
|
||||
|
||||
|
||||
def test_small_never_bypasses_governance():
|
||||
"""Invariant: 'small' means proportional, not bypassed."""
|
||||
d = select_depth("LOCAL_CHANGE")
|
||||
assert "classification" in " ".join(d.ceremony)
|
||||
assert "durable result" in " ".join(d.ceremony)
|
||||
|
||||
|
||||
def test_one_line_typo_no_six_artefacts():
|
||||
"""Invariant: a one-line typo doesn't create meaningless ceremony."""
|
||||
d = select_depth("LOCAL_CHANGE")
|
||||
assert len(d.ceremony) <= 5 # classification + scope + verify + durable
|
||||
assert "patterns" in " ".join(d.bypassed)
|
||||
@@ -0,0 +1,587 @@
|
||||
"""test_construction_interface.py — SCAFFOLD-TRANCHE-007 test plans
|
||||
§29 (interface core), §30 (authority), §31 (pattern precision — integration),
|
||||
§17 (provider independence)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _fixture_authorities():
|
||||
"""A self-contained project-scaffold authority (external-project mode
|
||||
without any real external repo — §16)."""
|
||||
spine = ss.new_doc()
|
||||
ss.add_entity(
|
||||
spine, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(spine, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
return spine
|
||||
|
||||
|
||||
def _iface(tmp_path, builder="test-builder", with_authority=True):
|
||||
store = str(tmp_path / ".rune" / "scaffold")
|
||||
iface = ci.ConstructionInterface(builder=builder, project_store=store, project_id="fixture-proj")
|
||||
if with_authority:
|
||||
# inject the fixture authority INTO the fixture scaffold store so
|
||||
# external-project mode has structural truth to consult
|
||||
iface.scaffold.init(purpose="fixture")
|
||||
doc = iface.scaffold.load()
|
||||
for e in _fixture_authorities()["entities"]:
|
||||
doc["entities"].append(json.loads(json.dumps(e)))
|
||||
for r in _fixture_authorities()["relationships"]:
|
||||
doc["relationships"].append(json.loads(json.dumps(r)))
|
||||
iface.scaffold.save(doc)
|
||||
return iface
|
||||
|
||||
|
||||
def _submit(iface, **kw):
|
||||
payload = dict(
|
||||
objective="persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=[],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by fixture authority",
|
||||
)
|
||||
payload.update(kw)
|
||||
return iface.submit_need(**payload)
|
||||
|
||||
|
||||
# ── §29 interface core ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_29a_submit_need(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
assert env["schema"] == ci.INTERFACE_SCHEMA_VERSION
|
||||
assert env["operation"] == "submit_need"
|
||||
assert env["success"]
|
||||
assert env["ids"]["need_id"]
|
||||
assert env["state"]["resolution"] == "REUSE"
|
||||
assert env["state"]["builder"] == "test-builder"
|
||||
|
||||
|
||||
def test_29b_retrieve_decision(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
cid = env["ids"]["contract_id"]
|
||||
d = iface.inspect_decision(cid)
|
||||
assert d["success"]
|
||||
assert d["state"]["resolution"] == "REUSE"
|
||||
assert d["state"]["toolchain"]["selected_language"] == "python"
|
||||
assert any(f["candidate"] == "capability:exporter" for f in d["state"]["fits"])
|
||||
|
||||
|
||||
def test_29c_retrieve_contract(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
r = iface.resume(cid)
|
||||
assert r["success"]
|
||||
assert r["state"]["phase"] == "draft"
|
||||
assert r["state"]["need"]["objective"] == "persistent project export"
|
||||
|
||||
|
||||
def test_29d_allowed_scope(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
s = iface.get_scope(cid)
|
||||
assert s["success"]
|
||||
assert "allowed_paths" in s["state"]
|
||||
assert "affected_structural_entities" in s["state"]
|
||||
assert "unresolved_surfaces" in s["state"]
|
||||
assert "layout_obligations" in s["state"]
|
||||
|
||||
|
||||
def test_29e_obligations(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
assert o["success"]
|
||||
assert o["state"]["obligations"]
|
||||
provs = {x["provenance"] for x in o["state"]["obligations"]}
|
||||
assert provs <= {"STRUCTURAL", "PATTERN", "LAYOUT_CONTRACT", "CONTRACT"}
|
||||
|
||||
|
||||
def test_29f_report_observation(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
r = iface.report_observation(cid, [{"entity": "capability:exporter", "detail": "exercised by new test"}])
|
||||
assert r["success"]
|
||||
assert r["state"]["recorded"] == 1
|
||||
assert "cannot self-certify" not in r["state"]["note"]
|
||||
assert "determined by Scaffolding gates" in r["state"]["note"]
|
||||
|
||||
|
||||
def test_29g_verification_state(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
v = iface.verify(cid, changed_files=["src/new_thing.py"])
|
||||
assert v["success"] or v["errors"]
|
||||
assert "scope_check" in v["state"] or v["errors"]
|
||||
|
||||
|
||||
def test_29h_resume_fresh_process(tmp_path):
|
||||
"""§11 THE MAJOR ACCEPTANCE CONDITION: a fresh builder process resumes
|
||||
from contract id alone."""
|
||||
iface = _iface(tmp_path, builder="first-builder")
|
||||
env = iface.submit_need(
|
||||
"persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["src/export_module.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by fixture authority",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
iface.transition(cid, "approved")
|
||||
script = f"""
|
||||
import sys, json
|
||||
sys.path.insert(0, {os.path.join(ROOT, "src")!r})
|
||||
from rune import construction_interface as ci
|
||||
iface = ci.ConstructionInterface(builder="fresh-builder",
|
||||
project_store={str(tmp_path / ".rune" / "scaffold")!r},
|
||||
project_id="fixture-proj")
|
||||
env = iface.resume({cid!r})
|
||||
print(json.dumps(env))
|
||||
"""
|
||||
r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, r.stderr
|
||||
env = json.loads(r.stdout)
|
||||
assert env["success"]
|
||||
st = env["state"]
|
||||
assert st["need"]["objective"] == "persistent project export"
|
||||
assert st["decision"]["resolution"] == "REUSE"
|
||||
assert st["phase"] == "approved"
|
||||
assert st["scope"]
|
||||
assert st["obligations"]
|
||||
assert st["next_action"]
|
||||
assert st["builder"] == "fresh-builder" # this session's identity
|
||||
|
||||
|
||||
def test_29i_json_roundtrip(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
blob = json.dumps(env)
|
||||
back = json.loads(blob)
|
||||
assert back["operation"] == env["operation"]
|
||||
assert back["state"]["resolution"] == env["state"]["resolution"]
|
||||
|
||||
|
||||
def test_29j_unknown_contract(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
for op in (
|
||||
lambda: iface.inspect_decision("nope"),
|
||||
lambda: iface.get_scope("nope"),
|
||||
lambda: iface.get_obligations("nope"),
|
||||
lambda: iface.resume("nope"),
|
||||
):
|
||||
env = op()
|
||||
assert not env["success"]
|
||||
assert env["errors"][0]["error"] == ci.Errors.UNKNOWN_CONTRACT
|
||||
|
||||
|
||||
def test_29k_approval_required(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = iface.submit_need(
|
||||
"build a whole new application from scratch",
|
||||
constraints={},
|
||||
affected_surface=[],
|
||||
evidence_refs=["x"],
|
||||
approval_context="",
|
||||
) # no approval context
|
||||
# new-capability construction without approval context → gate fires
|
||||
if env["state"]["approval"]["required"]:
|
||||
assert env["errors"]
|
||||
assert any(e["error"] == ci.Errors.APPROVAL_REQUIRED for e in env["errors"])
|
||||
else:
|
||||
# covered path must say so
|
||||
assert env["state"]["approval"]["covered_by"] or not env["state"]["approval"]["reasons"]
|
||||
|
||||
|
||||
def test_29l_scope_violation_refused(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = iface.submit_need(
|
||||
"persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["src/export_module.py"], # scoped surface
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by fixture authority",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
iface.transition(cid, "approved")
|
||||
iface.transition(cid, "building")
|
||||
iface.transition(cid, "verifying")
|
||||
v = iface.verify(cid, changed_files=["src/completely/unrelated.py"])
|
||||
# out-of-scope change must surface as a violation, never green
|
||||
assert v["state"]["scope_check"]["verdict"] == "red" or any(
|
||||
e["error"] == ci.Errors.SCOPE_VIOLATION for e in v["errors"]
|
||||
)
|
||||
|
||||
|
||||
def test_29m_failed_verification_cannot_close(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
# contract is in draft — closure must be refused by the phase map
|
||||
env = iface.close(cid, observed_evidence=[{"entity": "capability:exporter", "detail": "claimed done"}])
|
||||
assert not env["success"]
|
||||
assert env["errors"][0]["error"] == ci.Errors.VERIFICATION_FAILED
|
||||
|
||||
|
||||
def test_29n_stale_state_store_write(tmp_path):
|
||||
"""STORE_WRITE_FAILED: an unwritable store surfaces as an explicit
|
||||
error, not a silent lie."""
|
||||
iface = _iface(tmp_path)
|
||||
# break the store path
|
||||
iface.scaffold.current = "/proc/nonexistent/current.json"
|
||||
env = iface.report_observation("any", [{"entity": "x"}])
|
||||
assert not env["success"] or env["errors"]
|
||||
|
||||
|
||||
# ── §30 authority ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_30a_rune_mode_reads_rune_authority():
|
||||
"""Rune-internal mode consults the real Spine store (read-only)."""
|
||||
iface = ci.ConstructionInterface(builder="t")
|
||||
auth = iface._authorities()
|
||||
assert "rune_spine" in auth
|
||||
assert iface.mode == "rune_internal"
|
||||
|
||||
|
||||
def test_30b_external_mode_reads_project_scaffold(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
auth = iface._authorities()
|
||||
assert "rune_spine" not in auth # external project has no Rune Spine
|
||||
assert iface.mode == "external_project"
|
||||
assert auth["project_scaffold"]["entities"]
|
||||
|
||||
|
||||
def test_30c_builder_cannot_rewrite_structural_authority():
|
||||
"""The interface exposes NO operation that writes structural truth.
|
||||
(The only writes are contract/observation/session records in the
|
||||
scaffold store — never Spine entities.)"""
|
||||
public = [m for m in dir(ci.ConstructionInterface) if not m.startswith("_")]
|
||||
for forbidden in ("rewrite_spine", "set_entity", "promote", "supersede", "build_spine", "add_relationship"):
|
||||
assert forbidden not in public
|
||||
|
||||
|
||||
def test_30d_builder_cannot_override_user_locked(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
from rune import layout_contract as lc
|
||||
|
||||
doc = iface.scaffold.load()
|
||||
lc.define_region(doc, "footer-bar", provenance="scaffold")
|
||||
lc.bind_component(doc, "version_display", "footer-bar", lock=True, provenance="user", order=0)
|
||||
iface.scaffold.save(doc)
|
||||
# no interface operation amends layout at all
|
||||
public = [m for m in dir(ci.ConstructionInterface) if not m.startswith("_")]
|
||||
assert not any("layout" in m and "amend" in m for m in public)
|
||||
# and the direct amend path still refuses non-user authority
|
||||
doc2 = iface.scaffold.load()
|
||||
with pytest.raises(ValueError):
|
||||
lc.amend_layout_contract(
|
||||
doc2, "version_display", new_region="header", authorized_by="builder", reason="prefer it"
|
||||
)
|
||||
|
||||
|
||||
def test_30e_builder_cannot_self_certify(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
# no interface operation takes "done=True"
|
||||
v = iface.verify(cid)
|
||||
assert "cannot self-certify" in v["state"]["remaining"]
|
||||
# closure from draft fails
|
||||
env = iface.close(cid, observed_evidence=[{"entity": "x"}])
|
||||
assert not env["success"]
|
||||
|
||||
|
||||
def test_30f_builder_identity_neutral(tmp_path):
|
||||
"""§17: two builders with identical input get identical construction
|
||||
behavior (identity recorded, semantics unchanged)."""
|
||||
results = []
|
||||
for b in ("mimo", "kimi", "deterministic-script"):
|
||||
iface = _iface(tmp_path / b, builder=b)
|
||||
env = _submit(iface)
|
||||
results.append(
|
||||
(
|
||||
env["state"]["resolution"],
|
||||
env["state"]["completeness"],
|
||||
env["state"]["toolchain"] if "toolchain" in env["state"] else None,
|
||||
[o["id"] for o in iface.get_obligations(env["ids"]["contract_id"])]["obligations"] if False else None,
|
||||
)
|
||||
)
|
||||
resolutions = {r[0] for r in results}
|
||||
completions = {r[1] for r in results}
|
||||
assert len(resolutions) == 1 and len(completions) == 1
|
||||
|
||||
|
||||
# ── §31 pattern precision (integration through the interface) ───────────────
|
||||
|
||||
|
||||
def test_31a_ui_task_derives_ui_obligations(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
from rune import layout_contract as lc
|
||||
|
||||
doc = iface.scaffold.load()
|
||||
lc.define_region(doc, "footer-bar", provenance="scaffold")
|
||||
lc.bind_component(doc, "version_display", "footer-bar", provenance="scaffold")
|
||||
iface.scaffold.save(doc)
|
||||
env = iface.submit_need(
|
||||
"adjust the tui version widget",
|
||||
constraints={},
|
||||
affected_surface=["ui:version_display"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
ids = {x["id"] for x in o["state"]["obligations"]}
|
||||
assert any("ui-" in i or "layout:" in i for i in ids)
|
||||
|
||||
|
||||
def test_31b_non_ui_task_no_active_ui_obligations(tmp_path):
|
||||
"""§0 regression through the interface: non-UI work gets NO active
|
||||
ui-* obligations."""
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
cid = env["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
ui_active = [x for x in o["state"]["obligations"] if x["id"].startswith("ui-")]
|
||||
assert ui_active == []
|
||||
|
||||
|
||||
def test_31c_release_task_derives_release_obligations(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
# the fixture authority needs the deploy structural truth for fit
|
||||
doc = iface.scaffold.load()
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="module:deploy_stage",
|
||||
kind="module",
|
||||
identities={"path": "scripts/deploy_stage.py"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(doc, id="contract:monotonic_promotion", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="module:deploy_stage", to_id="contract:monotonic_promotion", evidence_refs=["f"]
|
||||
)
|
||||
iface.scaffold.save(doc)
|
||||
env = iface.submit_need(
|
||||
"repair the deploy promotion ordering",
|
||||
constraints={"contracts": ["contract:monotonic_promotion"]},
|
||||
affected_surface=["scripts/deploy_stage.py", "scripts/deploy_promotion.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
ids = {x["id"] for x in o["state"]["obligations"]}
|
||||
assert "rel-monotonic-promotion" in ids or any("rel-" in i for i in ids)
|
||||
|
||||
|
||||
def test_31d_non_release_task_no_promotion_obligations(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
cid = env["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
rel_active = [x for x in o["state"]["obligations"] if x["id"].startswith("rel-")]
|
||||
assert rel_active == []
|
||||
|
||||
|
||||
def test_31g_partial_pattern_inspectable_without_activation(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = iface.submit_need(
|
||||
"adjust the tui backlog plumbing",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["src/rune/tui_backlog.py", "src/export_module.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
d = iface.inspect_decision(cid)
|
||||
considered = {a["pattern"]: a["verdict"] for a in d["state"]["patterns_considered"]}
|
||||
# PARTIAL patterns remain VISIBLE as candidates (inspectable)
|
||||
assert considered
|
||||
# but their UI obligations did not enter the ACTIVE obligation set
|
||||
o = iface.get_obligations(cid)
|
||||
ui_active = [x for x in o["state"]["obligations"] if x["id"].startswith("ui-")]
|
||||
assert ui_active == []
|
||||
|
||||
|
||||
# ── CLI transport ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cli_roundtrip(tmp_path):
|
||||
store = tmp_path / ".rune" / "scaffold"
|
||||
iface = ci.ConstructionInterface(builder="seed", project_store=str(store), project_id="cli-fixture")
|
||||
iface.scaffold.init(purpose="cli")
|
||||
doc = iface.scaffold.load()
|
||||
for e in _fixture_authorities()["entities"]:
|
||||
doc["entities"].append(json.loads(json.dumps(e)))
|
||||
for r in _fixture_authorities()["relationships"]:
|
||||
doc["relationships"].append(json.loads(json.dumps(r)))
|
||||
iface.scaffold.save(doc)
|
||||
env = {
|
||||
"objective": "persistent project export",
|
||||
"constraints": {"contracts": ["contract:csv_export"]},
|
||||
"affected_surface": [],
|
||||
"evidence_refs": ["x"],
|
||||
"approval_context": "covered",
|
||||
}
|
||||
r = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"rune.construction_interface",
|
||||
"submit-need",
|
||||
json.dumps(env),
|
||||
"--builder",
|
||||
"cli-builder",
|
||||
"--project-store",
|
||||
str(store),
|
||||
"--project-id",
|
||||
"cli-fixture",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "PYTHONPATH": os.path.join(ROOT, "src")},
|
||||
)
|
||||
assert r.returncode == 0, r.stderr[-400:]
|
||||
out = json.loads(r.stdout[r.stdout.index("{") :])
|
||||
assert out["success"]
|
||||
assert out["state"]["resolution"] == "REUSE"
|
||||
# resume through the CLI from the returned contract id
|
||||
r2 = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"rune.construction_interface",
|
||||
"resume",
|
||||
out["ids"]["contract_id"],
|
||||
"--builder",
|
||||
"cli-2",
|
||||
"--project-store",
|
||||
str(store),
|
||||
"--project-id",
|
||||
"cli-fixture",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "PYTHONPATH": os.path.join(ROOT, "src")},
|
||||
)
|
||||
assert r2.returncode == 0, r2.stderr[-400:]
|
||||
out2 = json.loads(r2.stdout[r2.stdout.index("{") :])
|
||||
assert out2["state"]["need"]["objective"] == "persistent project export"
|
||||
|
||||
|
||||
# ── §28 capabilities / schema negotiation (SCAFFOLD-TRANCHE-008) ────────────
|
||||
|
||||
|
||||
def test_capabilities_operation(tmp_path):
|
||||
"""Builders can detect the supported interface schema + operations
|
||||
BEFORE calling anything (T7 §28 requirement, closed by this tranche)."""
|
||||
iface = _iface(tmp_path)
|
||||
env = iface.capabilities()
|
||||
assert env["success"]
|
||||
st = env["state"]
|
||||
assert st["schema_version"] == ci.INTERFACE_SCHEMA_VERSION
|
||||
for op in (
|
||||
"submit_need",
|
||||
"inspect_decision",
|
||||
"get_scope",
|
||||
"get_obligations",
|
||||
"report_observation",
|
||||
"verify",
|
||||
"transition",
|
||||
"close",
|
||||
"resume",
|
||||
"capabilities",
|
||||
):
|
||||
assert op in st["operations"]
|
||||
assert ci.Errors.SCOPE_VIOLATION in st["error_states"]
|
||||
assert ci.Errors.UNKNOWN_CONTRACT in st["error_states"]
|
||||
# mode + project identity exposed for builder routing
|
||||
assert st["mode"] == "external_project"
|
||||
assert st["project"] == "fixture-proj"
|
||||
|
||||
|
||||
def test_capabilities_cli_roundtrip(tmp_path):
|
||||
"""The CLI transport exposes the same capability set (§3 transport
|
||||
parity: the builder must not need the Python API for schema detection)."""
|
||||
import subprocess
|
||||
|
||||
env = {"objective": "x", "constraints": {}, "affected_surface": [], "evidence_refs": [], "approval_context": ""}
|
||||
r = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"rune.construction_interface",
|
||||
"capabilities",
|
||||
"--builder",
|
||||
"probe",
|
||||
"--project-store",
|
||||
str(tmp_path / ".rune" / "scaffold"),
|
||||
"--project-id",
|
||||
"fixture-proj",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "PYTHONPATH": os.path.join(ROOT, "src")},
|
||||
)
|
||||
assert r.returncode == 0, r.stderr[-300:]
|
||||
out = json.loads(r.stdout[r.stdout.index("{") :])
|
||||
assert out["success"]
|
||||
assert out["state"]["schema_version"] == ci.INTERFACE_SCHEMA_VERSION
|
||||
assert "capabilities" in out["state"]["operations"]
|
||||
|
||||
|
||||
def test_15u_discover_active_contract_migrated_to_interface(tmp_path):
|
||||
"""§15U simplification: resume discovery lives in the INTERFACE (one
|
||||
canonical home), not in session-driver scripts. The drivers' helpers
|
||||
are shims over this."""
|
||||
from rune import construction_interface as ci
|
||||
store = tmp_path / "scaffold"
|
||||
iface = ci.ConstructionInterface(builder="probe",
|
||||
project_store=str(store),
|
||||
project_id="t15u")
|
||||
iface.scaffold.init(purpose="15u probe")
|
||||
empty = ci.ConstructionInterface.discover_active_contract(str(store))
|
||||
assert not empty["success"], "empty store must not invent a contract"
|
||||
doc = iface.scaffold.load()
|
||||
from rune import structural_store as ss
|
||||
ss.add_entity(doc, id="contract:spec-15u", kind="contract",
|
||||
identities={"objective": "spec"}, evidence_refs=["e"])
|
||||
ss.add_entity(doc, id="capability:planned", kind="capability",
|
||||
maturity="DECLARED", evidence_refs=["e"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:planned",
|
||||
to_id="contract:spec-15u", evidence_refs=["e"])
|
||||
iface.scaffold.commit(doc, what="seed 15u spec")
|
||||
sub = iface.submit_need(
|
||||
"build the 15u module",
|
||||
constraints={"environment": {"os": "linux"},
|
||||
"contracts": ["contract:spec-15u"]},
|
||||
evidence_refs=["e"], approval_context="15u")
|
||||
assert sub["success"]
|
||||
found = ci.ConstructionInterface.discover_active_contract(str(store))
|
||||
assert found["success"]
|
||||
assert found["ids"]["contract_id"] == sub["ids"]["contract_id"]
|
||||
# capabilities() advertises the new op
|
||||
assert "discover_active_contract" in \
|
||||
iface.capabilities()["state"]["operations"]
|
||||
@@ -0,0 +1,526 @@
|
||||
"""test_construction_mode.py — SCAFFOLD-TRANCHE-0010: §19 classification
|
||||
matrix, §20 context-sensitivity, §3 BUILD_NEW != PROJECT_BUILD, §23
|
||||
override provenance, shared-context projection."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_mode as cm # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
|
||||
# ── §19 classification matrix ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_19a_typo_fix_is_local_change():
|
||||
d = cm.classify_mode("fix this typo in the existing module", project_exists=True)
|
||||
assert d.mode in ("LOCAL_CHANGE", "EXISTING_PROJECT_CHANGE")
|
||||
assert d.mode != "PROJECT_BUILD"
|
||||
|
||||
|
||||
def test_19b_add_csv_export_existing_app():
|
||||
d = cm.classify_mode("add CSV export to this existing app", project_exists=True)
|
||||
assert d.mode == "EXISTING_PROJECT_CHANGE"
|
||||
|
||||
|
||||
def test_19c_schema_replacement_migration():
|
||||
d = cm.classify_mode("replace the storage schema in this existing project", project_exists=True)
|
||||
assert d.mode == "EXISTING_PROJECT_CHANGE"
|
||||
|
||||
|
||||
def test_19d_breaking_api_change_stays_existing_project():
|
||||
d = cm.classify_mode("make a breaking change to the existing public api", project_exists=True)
|
||||
assert d.mode == "EXISTING_PROJECT_CHANGE"
|
||||
|
||||
|
||||
def test_19e_standalone_scheduler_is_project_build():
|
||||
d = cm.classify_mode("build me a new standalone practice scheduler app", project_exists=False)
|
||||
assert d.mode == "PROJECT_BUILD"
|
||||
assert d.bootstrap_required is True
|
||||
|
||||
|
||||
def test_19f_new_web_service_is_project_build():
|
||||
d = cm.classify_mode("create a new web service for session notes", project_exists=False)
|
||||
assert d.mode == "PROJECT_BUILD"
|
||||
|
||||
|
||||
def test_19g_build_confidence_not_construction():
|
||||
d = cm.classify_mode("build my confidence with guitar improvisation")
|
||||
assert d.mode == "NOT_CONSTRUCTION"
|
||||
|
||||
|
||||
def test_19h_make_a_list_not_project_build():
|
||||
d = cm.classify_mode("make me a list of chord substitutions")
|
||||
assert d.mode == "NOT_CONSTRUCTION"
|
||||
|
||||
|
||||
def test_19i_ambiguous_is_unknown_not_project_build():
|
||||
d = cm.classify_mode("build something to help with this")
|
||||
assert d.mode == "UNKNOWN"
|
||||
assert d.clarification_required is True
|
||||
assert d.probe_questions
|
||||
|
||||
|
||||
def test_19j_capability_build_new_inside_rune_is_existing_project_change():
|
||||
"""§3 THE KEY DISTINCTION: capability resolution may be BUILD_NEW while
|
||||
construction mode remains EXISTING_PROJECT_CHANGE — new capability
|
||||
INSIDE the existing Rune project."""
|
||||
d = cm.classify_mode("implement a new parser module for Rune", target_project="rune", project_exists=True)
|
||||
assert d.mode == "EXISTING_PROJECT_CHANGE"
|
||||
assert d.mode != "PROJECT_BUILD"
|
||||
|
||||
|
||||
# ── §20 context-sensitivity: same request, different structure ──────────────
|
||||
|
||||
|
||||
def test_20_same_request_resolves_by_context():
|
||||
obj = "add appointment booking"
|
||||
a = cm.classify_mode(obj, target_project="scheduler-app", project_exists=True)
|
||||
b = cm.classify_mode(obj, target_project=None, project_exists=False)
|
||||
assert a.mode == "EXISTING_PROJECT_CHANGE"
|
||||
assert b.mode in ("UNKNOWN", "PROJECT_BUILD")
|
||||
assert a.mode != b.mode
|
||||
|
||||
|
||||
def test_20b_existing_project_contradicts_standalone_language():
|
||||
"""§5: project evidence wins over standalone wording — contradictory
|
||||
evidence → UNKNOWN with clarification, never silent PROJECT_BUILD."""
|
||||
d = cm.classify_mode("build a standalone export tool", target_project="rune", project_exists=True)
|
||||
assert d.mode == "UNKNOWN"
|
||||
assert d.clarification_required is True
|
||||
|
||||
|
||||
# ── UNKNOWN discipline ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unknown_never_silently_escalates():
|
||||
for obj in ("build something to help with this", "create a thing for the workflow", "implement improvements"):
|
||||
d = cm.classify_mode(obj)
|
||||
assert d.mode in ("UNKNOWN",)
|
||||
assert d.clarification_required
|
||||
|
||||
|
||||
def test_empty_objective_unknown():
|
||||
d = cm.classify_mode("")
|
||||
assert d.mode == "UNKNOWN"
|
||||
assert d.probe_questions
|
||||
|
||||
|
||||
# ── §23 manual override ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_23_override_from_unknown():
|
||||
d = cm.classify_mode("build something to help with this")
|
||||
assert d.mode == "UNKNOWN"
|
||||
forced = cm.classify_mode(
|
||||
"build something to help with this", explicit_override="PROJECT_BUILD", override_authority="user"
|
||||
)
|
||||
assert forced.mode == "PROJECT_BUILD"
|
||||
assert forced.override == {"authority": "user", "from_mode": None}
|
||||
assert forced.bootstrap_required is True
|
||||
|
||||
|
||||
def test_23_override_to_existing_project_validated():
|
||||
d = cm.classify_mode("build a standalone export tool", project_exists=True)
|
||||
assert d.mode == "UNKNOWN"
|
||||
forced = cm.classify_mode(
|
||||
"build a standalone export tool",
|
||||
project_exists=True,
|
||||
target_project="rune",
|
||||
explicit_override="EXISTING_PROJECT_CHANGE",
|
||||
override_authority="operator",
|
||||
)
|
||||
assert forced.mode == "EXISTING_PROJECT_CHANGE"
|
||||
assert forced.override["authority"] == "operator"
|
||||
|
||||
|
||||
def test_23_override_invalid_mode_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
cm.classify_mode("anything", explicit_override="MAGIC_MODE")
|
||||
|
||||
|
||||
# ── model proposal boundary ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_model_proposal_recorded_never_authority():
|
||||
prop = {"pattern_id": "x", "provider": "p", "confidence": 0.4}
|
||||
d = cm.classify_mode("fix this typo in the existing module", project_exists=True, model_proposal=prop)
|
||||
assert d.mode == "LOCAL_CHANGE" # deterministic evidence decided
|
||||
assert d.proposed_by_model == prop # recorded, not governing
|
||||
|
||||
|
||||
# ── to_dict roundtrip ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mode_decision_serializable():
|
||||
d = cm.classify_mode("add CSV export to this existing app", target_project="garden", project_exists=True)
|
||||
blob = d.to_dict()
|
||||
assert blob["mode"] == "EXISTING_PROJECT_CHANGE"
|
||||
assert blob["target_project"] == "garden"
|
||||
assert isinstance(blob["evidence"], list)
|
||||
|
||||
|
||||
# ── Tranche 10: interface consumers + shared context ────────────────────────
|
||||
|
||||
|
||||
def test_interface_classify_equivalence(tmp_path):
|
||||
"""§24: ONE classifier — the Construction Interface and the direct
|
||||
call produce identical semantics for identical input."""
|
||||
from rune import construction_interface as ci
|
||||
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="mimo", project_store=str(tmp_path / ".rune" / "scaffold"), project_id="fx"
|
||||
)
|
||||
iface.scaffold.init(purpose="t")
|
||||
via_interface = iface.classify("build me a new standalone practice scheduler app", project_exists=False)
|
||||
direct = cm.classify_mode("build me a new standalone practice scheduler app", project_exists=False)
|
||||
assert via_interface["state"]["mode"] == direct.mode == "PROJECT_BUILD"
|
||||
# builder identity recorded, semantics unchanged
|
||||
assert via_interface["state"]["builder"] == "mimo"
|
||||
|
||||
|
||||
def test_interface_classify_unknown_carries_probes(tmp_path):
|
||||
from rune import construction_interface as ci
|
||||
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="mimo", project_store=str(tmp_path / ".rune" / "scaffold"), project_id="fx"
|
||||
)
|
||||
iface.scaffold.init(purpose="t")
|
||||
env = iface.classify("build something to help with this")
|
||||
assert env["state"]["mode"] == "UNKNOWN"
|
||||
assert env["state"]["probe_questions"]
|
||||
assert not env["success"] # UNKNOWN is a non-success that asks
|
||||
|
||||
|
||||
def test_construction_context_projection(tmp_path):
|
||||
from rune import construction_interface as ci
|
||||
|
||||
iface = ci.ConstructionInterface(builder="b", project_store=str(tmp_path / ".rune" / "scaffold"), project_id="fx")
|
||||
iface.scaffold.init(purpose="t")
|
||||
# seed the structural truth the Need's fit requires
|
||||
from rune import structural_store as ss
|
||||
|
||||
doc0 = iface.scaffold.load()
|
||||
ss.add_entity(
|
||||
doc0, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc0, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc0, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
iface.scaffold.save(doc0)
|
||||
# no active construction
|
||||
empty = iface.construction_context()
|
||||
assert empty["state"]["active"] is False
|
||||
# with an active contract
|
||||
env = iface.submit_need(
|
||||
"persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["src/export.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
ctx = iface.construction_context(cid)
|
||||
st = ctx["state"]
|
||||
assert st["active"] is True
|
||||
assert st["need"] == "persistent project export"
|
||||
assert st["phase"] == "draft"
|
||||
assert st["scope"] == ["src/export.py"]
|
||||
assert st["next_action"] == "review for approval"
|
||||
|
||||
|
||||
def test_construction_mode_in_decision_trace(tmp_path):
|
||||
"""make_decision carries the contextual mode — ONE classifier behind
|
||||
both the interface and the decision path."""
|
||||
from rune import construction_interface as ci
|
||||
|
||||
iface = ci.ConstructionInterface(builder="b", project_store=str(tmp_path / ".rune" / "scaffold"), project_id="fx")
|
||||
iface.scaffold.init(purpose="t")
|
||||
doc = iface.scaffold.load()
|
||||
from rune import structural_store as ss
|
||||
|
||||
ss.add_entity(
|
||||
doc, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
iface.scaffold.save(doc)
|
||||
env = iface.submit_need(
|
||||
"persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=[],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
doc2 = iface.scaffold.load()
|
||||
c = iface.scaffold.contract(doc2, cid)
|
||||
mode = c["ext"]["decision_trace"]["construction_mode"]
|
||||
assert mode["mode"] == "EXISTING_PROJECT_CHANGE" # rune-internal project
|
||||
|
||||
|
||||
# ── Tranche 11 §1: project-existence authority ──────────────────────────────
|
||||
|
||||
def test_11_rune_project_exists():
|
||||
from rune import construction_decision as cd
|
||||
exists, ev = cd._project_exists_evidence("rune", {})
|
||||
assert exists is True
|
||||
assert any("Rune Spine" in e for e in ev)
|
||||
|
||||
|
||||
def test_11_arbitrary_name_no_scaffold_is_false():
|
||||
from rune import construction_decision as cd
|
||||
doc = ss.new_doc() # empty authority — no project:foo anywhere
|
||||
exists, ev = cd._project_exists_evidence("foo", {"spine": doc})
|
||||
assert exists is False
|
||||
assert any("no structural evidence" in e for e in ev)
|
||||
|
||||
|
||||
def test_11_bound_scaffold_entity_is_true():
|
||||
from rune import construction_decision as cd
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(doc, id="project:scheduler", kind="project",
|
||||
identities={"purpose": "scheduling"}, evidence_refs=["e"])
|
||||
exists, ev = cd._project_exists_evidence("scheduler", {"scaffold": doc})
|
||||
assert exists is True
|
||||
assert any("project:scheduler" in e for e in ev)
|
||||
|
||||
|
||||
def test_11_missing_project_identity_is_unknown():
|
||||
from rune import construction_decision as cd
|
||||
exists, ev = cd._project_exists_evidence(None, {})
|
||||
assert exists is None
|
||||
assert any("UNKNOWN" in e for e in ev)
|
||||
|
||||
|
||||
def test_11_classifier_receives_evidence_not_invention():
|
||||
"""make_decision must pass STRUCTURAL existence to the classifier —
|
||||
an arbitrary project name with no scaffold reads exists=False, never
|
||||
invented True. Confirmed-nonexistence + standalone language is
|
||||
CONSISTENT evidence → PROJECT_BUILD (T10 §19e matrix holds)."""
|
||||
from rune import construction_decision as cd
|
||||
need = cd.Need("x", "build a standalone export tool", project="foo",
|
||||
constraints={}, affected_surface=[], evidence_refs=["e"],
|
||||
approval_context="c")
|
||||
doc = ss.new_doc() # empty: project:foo does NOT exist
|
||||
trace = cd.make_decision(need, authorities={"spine": doc},
|
||||
project_toolchain={"existing_language": "python"})
|
||||
mode = trace["construction_mode"]
|
||||
assert trace["project_existence"]["exists"] is False # evidenced, not invented
|
||||
assert mode["mode"] == "PROJECT_BUILD" # consistent: standalone + no existing project
|
||||
assert any("no structural evidence" in e
|
||||
for e in mode["evidence"]) # evidence carried through
|
||||
|
||||
|
||||
def test_11_scoped_change_against_nonexistent_project_does_not_manufacture():
|
||||
"""The REAL manufactured-existence danger: a change scoped to a project
|
||||
that has NO structural evidence must not silently claim
|
||||
EXISTING_PROJECT_CHANGE against it — the scope evidence is contradicted
|
||||
by proven non-existence, so UNKNOWN/clarify (T10 §5)."""
|
||||
from rune import construction_decision as cd
|
||||
need = cd.Need("y", "add CSV export to the project foo",
|
||||
project="foo",
|
||||
constraints={}, affected_surface=[], evidence_refs=["e"],
|
||||
approval_context="c")
|
||||
doc = ss.new_doc() # project:foo does NOT exist anywhere
|
||||
trace = cd.make_decision(need, authorities={"spine": doc},
|
||||
project_toolchain={"existing_language": "python"})
|
||||
assert trace["project_existence"]["exists"] is False
|
||||
mode = trace["construction_mode"]
|
||||
assert mode["mode"] in ("UNKNOWN", "PROJECT_BUILD")
|
||||
if mode["mode"] == "EXISTING_PROJECT_CHANGE":
|
||||
pytest.fail("manufactured existence: scoped change to a proven-"
|
||||
"nonexistent project claimed existing-project semantics")
|
||||
|
||||
|
||||
def test_11_scoped_change_to_existing_project_is_existing_change():
|
||||
from rune import construction_decision as cd
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(doc, id="project:scheduler", kind="project",
|
||||
identities={"purpose": "scheduling"}, evidence_refs=["e"])
|
||||
need = cd.Need("z", "add CSV export to the project scheduler",
|
||||
project="scheduler",
|
||||
constraints={}, affected_surface=[], evidence_refs=["e"],
|
||||
approval_context="c")
|
||||
trace = cd.make_decision(need, authorities={"spine": doc},
|
||||
project_toolchain={"existing_language": "python"})
|
||||
assert trace["project_existence"]["exists"] is True
|
||||
assert trace["construction_mode"]["mode"] == "EXISTING_PROJECT_CHANGE"
|
||||
|
||||
|
||||
# ── Tranche 11 §6-13: activation dispatcher + bootstrap + non-activation ────
|
||||
|
||||
from rune import construction_activation as ca # noqa: E402
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
|
||||
|
||||
def _need(project=None, obj="build a standalone export tool"):
|
||||
return cd.Need("n", obj, project=project, constraints={},
|
||||
affected_surface=[], evidence_refs=["e"],
|
||||
approval_context="c")
|
||||
|
||||
|
||||
def _mode(obj, project=None, exists=None):
|
||||
return cm.classify_mode(obj, target_project=project,
|
||||
project_exists=exists)
|
||||
|
||||
|
||||
def _s(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "rune")
|
||||
s.init(purpose="rune")
|
||||
return s
|
||||
|
||||
|
||||
def test_project_build_bootstraps_scaffold(tmp_path):
|
||||
"""§6/§24A: PROJECT_BUILD actually initializes a Project Scaffold with
|
||||
explicit UNKNOWN fields — nothing manufactured."""
|
||||
s = _s(tmp_path)
|
||||
d = _mode("build a new standalone practice scheduler app", exists=False)
|
||||
rec = ca.activate(d, need=_need("scheduler"), scaffold=s)
|
||||
assert rec["activated"] and rec["lifecycle"] == "project_build"
|
||||
ps = sc.Scaffold(tmp_path / ".rune" / "scaffold" / "projects" /
|
||||
"scheduler", "scheduler")
|
||||
pdoc = ps.load()
|
||||
p = ss.entity(pdoc, "project:scheduler")
|
||||
assert p is not None # scaffold initialized
|
||||
ext = p["ext"]
|
||||
assert ext["bootstrap"] == "initialized"
|
||||
assert ext["repository"] == "undecided" # §7: identity before repo
|
||||
assert ext["toolchain"] == "unresolved" # nothing manufactured
|
||||
assert ext["deployment"] == "unknown"
|
||||
|
||||
|
||||
def test_web_service_bootstraps_too(tmp_path):
|
||||
s = _s(tmp_path)
|
||||
d = _mode("create a new web service for session notes", exists=False)
|
||||
rec = ca.activate(d, need=_need("session-notes"), scaffold=s)
|
||||
assert rec["activated"]
|
||||
assert (tmp_path / ".rune" / "scaffold" / "projects" /
|
||||
"session-notes" / "current.json").exists()
|
||||
|
||||
|
||||
def test_existing_change_never_bootstraps_new_project(tmp_path):
|
||||
"""§24C: existing-project change must NOT create a new Project
|
||||
Scaffold."""
|
||||
s = _s(tmp_path)
|
||||
d = _mode("add CSV export to this existing app", project="rune",
|
||||
exists=True)
|
||||
rec = ca.activate(d, need=_need("rune", obj=d.mode), scaffold=s)
|
||||
assert rec["activated"] and rec["lifecycle"] == "proportional"
|
||||
assert not (tmp_path / ".rune" / "scaffold" / "projects" /
|
||||
"scheduler").exists()
|
||||
|
||||
|
||||
def test_ambiguous_never_activates(tmp_path):
|
||||
"""§24E/§9: UNKNOWN → no Project Scaffold, probes returned."""
|
||||
s = _s(tmp_path)
|
||||
d = _mode("build something to help with this")
|
||||
assert d.mode == "UNKNOWN"
|
||||
rec = ca.activate(d, need=_need(None, obj=d.mode and
|
||||
"build something to help with this"),
|
||||
scaffold=s)
|
||||
assert rec["activated"] is False
|
||||
assert rec["probe_questions"]
|
||||
assert not (tmp_path / ".rune" / "scaffold" / "projects").exists()
|
||||
|
||||
|
||||
def test_non_construction_touches_nothing(tmp_path):
|
||||
"""§24F/§10: NOT_CONSTRUCTION → zero construction state."""
|
||||
s = _s(tmp_path)
|
||||
d = cm.classify_mode("make me a list of chord substitutions")
|
||||
rec = ca.activate(d, need=_need(None, obj=d.mode and
|
||||
"make me a list"), scaffold=s)
|
||||
assert rec["activated"] is False
|
||||
assert (tmp_path / ".rune" / "scaffold" / "projects").exists() is False
|
||||
|
||||
|
||||
def test_same_name_with_scaffold_is_existing_change(tmp_path):
|
||||
"""§24G: same project name WITH a bound scaffold → existing semantics."""
|
||||
proj_dir = tmp_path / ".rune" / "scaffold" / "projects" / "scheduler"
|
||||
ps = sc.Scaffold(proj_dir, "scheduler")
|
||||
ps.init(purpose="scheduling")
|
||||
d = _mode("add CSV export to the scheduler app", project="scheduler",
|
||||
exists=True)
|
||||
rec = ca.activate(d, need=_need("scheduler", obj=d.mode and
|
||||
"add CSV export"), scaffold=_s(tmp_path))
|
||||
assert rec["lifecycle"] == "proportional"
|
||||
|
||||
|
||||
def test_same_name_without_evidence_not_manufactured_existing(tmp_path):
|
||||
"""§24H: same project name WITHOUT structural evidence must not become
|
||||
existing merely because a string was supplied."""
|
||||
d = cm.classify_mode("add CSV export to the scheduler app",
|
||||
target_project="scheduler", project_exists=False)
|
||||
assert d.mode in ("UNKNOWN", "PROJECT_BUILD")
|
||||
if d.mode == "EXISTING_PROJECT_CHANGE":
|
||||
pytest.fail("manufactured existence from a bare string")
|
||||
|
||||
|
||||
def test_dispatch_from_decision(tmp_path):
|
||||
"""§15: dispatch reads the mode from the make_decision trace."""
|
||||
s = _s(tmp_path)
|
||||
need = _need("scheduler")
|
||||
auth = {"spine": ss.new_doc()}
|
||||
trace = cd.make_decision(need, authorities=auth,
|
||||
project_toolchain={"existing_language":
|
||||
"python"})
|
||||
from rune.construction_mode import ConstructionMode
|
||||
md = ConstructionMode.from_dict(trace["construction_mode"])
|
||||
rec = ca.activate(md, need=need, scaffold=s)
|
||||
assert rec["mode"] == trace["construction_mode"]["mode"]
|
||||
|
||||
|
||||
def test_fresh_process_project_build_resume(tmp_path):
|
||||
"""§17: PROJECT_BUILD survives restart — durable state recovers mode,
|
||||
bootstrap, gaps, next action without transcript."""
|
||||
s = _s(tmp_path)
|
||||
d = _mode("build a new standalone practice scheduler app", exists=False)
|
||||
rec = ca.activate(d, need=_need("scheduler"), scaffold=s)
|
||||
assert rec["activated"]
|
||||
# fresh process: only durable state
|
||||
script = f'''
|
||||
import sys, json
|
||||
sys.path.insert(0, {os.path.join(os.path.dirname(__file__), "..", "src")!r})
|
||||
from rune import scaffold as sc, structural_store as ss
|
||||
ps = sc.Scaffold({str(tmp_path / ".rune" / "scaffold" / "projects" / "scheduler")!r},
|
||||
"scheduler")
|
||||
pdoc = ps.load()
|
||||
p = ss.entity(pdoc, "project:scheduler")
|
||||
ext = p["ext"]
|
||||
print(json.dumps({{"purpose": p["identities"]["purpose"],
|
||||
"bootstrap": ext["bootstrap"], "repository": ext["repository"],
|
||||
"toolchain": ext["toolchain"], "gaps": ext["requirement_gaps"]}}))
|
||||
'''
|
||||
r = subprocess.run([sys.executable, "-c", script],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, r.stderr
|
||||
recovered = json.loads(r.stdout)
|
||||
assert recovered["bootstrap"] == "initialized"
|
||||
assert recovered["repository"] == "undecided"
|
||||
assert recovered["gaps"]
|
||||
|
||||
|
||||
def test_rune_originated_need_same_classifier(tmp_path):
|
||||
"""§18: Rune-originated Need — same classifier, same dispatch. The
|
||||
origin is provenance, not a different path."""
|
||||
need = cd.Need("rune-originated", "build a new standalone test corpus "
|
||||
"manager", project=None,
|
||||
origin="rune",
|
||||
constraints={}, affected_surface=[],
|
||||
evidence_refs=["approved task determined standalone "
|
||||
"managed project warranted"],
|
||||
approval_context="approval-gated: no consequential action")
|
||||
d = cm.classify_mode(need.objective, project_exists=False)
|
||||
assert d.mode == "PROJECT_BUILD"
|
||||
rec = ca.activate(d, need=need, scaffold=_s(tmp_path))
|
||||
assert rec["activated"] and rec["lifecycle"] == "project_build"
|
||||
@@ -0,0 +1,471 @@
|
||||
"""test_construction_pattern.py — SCAFFOLD-TRANCHE-006 test plans §28-§32."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
from rune import construction_pattern as cp # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune.construction_patterns_builtin import ( # noqa: E402
|
||||
BUILTIN_PATTERNS,
|
||||
RELEASE_DEPLOYMENT,
|
||||
get_pattern,
|
||||
)
|
||||
|
||||
import construction_guards as cg # noqa: E402
|
||||
|
||||
|
||||
def _spine():
|
||||
spine = ss.new_doc()
|
||||
ss.add_entity(
|
||||
spine,
|
||||
id="module:deploy_stage",
|
||||
kind="module",
|
||||
identities={"path": "scripts/deploy_stage.py"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(spine, id="contract:monotonic_promotion", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine,
|
||||
type="implements",
|
||||
from_id="module:deploy_stage",
|
||||
to_id="contract:monotonic_promotion",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(
|
||||
spine,
|
||||
id="module:deploy_qualify",
|
||||
kind="module",
|
||||
identities={"path": "scripts/deploy_qualify.py"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(spine, id="contract:fail_closed_deployment", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine,
|
||||
type="implements",
|
||||
from_id="module:deploy_qualify",
|
||||
to_id="contract:fail_closed_deployment",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
return spine
|
||||
|
||||
|
||||
def _need(**kw):
|
||||
d = dict(
|
||||
need_id="n6",
|
||||
objective="repair the deploy promotion ordering",
|
||||
origin="user",
|
||||
constraints={"contracts": ["contract:monotonic_promotion"]},
|
||||
affected_surface=["scripts/deploy_stage.py", "scripts/deploy_promotion.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
d.update(kw)
|
||||
return cd.Need(**d)
|
||||
|
||||
|
||||
def _surface(need):
|
||||
authorities = {"rune_spine": _spine()}
|
||||
return cd.resolve_affected_surface(need, authorities), authorities
|
||||
|
||||
|
||||
# ── §28 pattern object ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_28a_roundtrip():
|
||||
d = RELEASE_DEPLOYMENT.to_dict()
|
||||
p2 = cp.ConstructionPattern.from_dict(d)
|
||||
assert p2.to_dict() == d
|
||||
|
||||
|
||||
def test_28b_open_world_ext():
|
||||
p = cp.ConstructionPattern("p", "purpose", wibble="future-data")
|
||||
assert p.to_dict()["wibble"] == "future-data"
|
||||
assert cp.ConstructionPattern.from_dict(p.to_dict()).ext["wibble"] == "future-data"
|
||||
|
||||
|
||||
def test_28c_revision_preserved_and_advances():
|
||||
p2 = RELEASE_DEPLOYMENT.revise(
|
||||
reason="new incident evidence", evidence=["new-incident"], changes={"maturity": "EXERCISED"}
|
||||
)
|
||||
assert p2.revision == RELEASE_DEPLOYMENT.revision + 1
|
||||
hist = p2.ext["revision_history"]
|
||||
assert hist[-1]["prior_revision"] == RELEASE_DEPLOYMENT.revision
|
||||
assert hist[-1]["evidence"] == ["new-incident"]
|
||||
# revision history survives a roundtrip (§17: old-rule traceability)
|
||||
assert cp.ConstructionPattern.from_dict(p2.to_dict()).ext["revision_history"] == hist
|
||||
# original untouched (no invisible mutation)
|
||||
assert RELEASE_DEPLOYMENT.maturity == "UNIT-PROVEN"
|
||||
|
||||
|
||||
def test_28d_provenance_preserved():
|
||||
d = RELEASE_DEPLOYMENT.to_dict()
|
||||
# provenance entries are enriched "ref (why)" strings — check by ref
|
||||
assert any(e.startswith("maintenance:deploy-order-race") for e in d["provenance"])
|
||||
assert any(e.startswith("DEPLOY-MONOTONIC-001") for e in d["provenance"])
|
||||
assert cp.ConstructionPattern.from_dict(d).provenance == d["provenance"]
|
||||
|
||||
|
||||
def test_28e_maturity_preserved():
|
||||
for p in BUILTIN_PATTERNS:
|
||||
assert p.maturity in ("CANDIDATE", "UNIT-PROVEN", "EXERCISED", "REPEATEDLY-PROVEN", "DEGRADED")
|
||||
# initial patterns stay appropriately immature (§15)
|
||||
assert all(p.maturity in ("CANDIDATE", "UNIT-PROVEN") for p in BUILTIN_PATTERNS)
|
||||
|
||||
|
||||
def test_28f_obligation_validation():
|
||||
with pytest.raises(ValueError):
|
||||
cp.ConstructionPattern("p", "purpose", obligations=[{"id": "x"}]) # missing text/when
|
||||
|
||||
|
||||
def test_28g_registry_revision_guard():
|
||||
assert get_pattern("release-deployment-change") is not None
|
||||
with pytest.raises(ValueError):
|
||||
get_pattern("release-deployment-change", revision=99)
|
||||
|
||||
|
||||
# ── §29 applicability ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_29a_matching_evidence_applies():
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
assert app["verdict"] == cp.APPLIES
|
||||
assert app["evidence"] # evidence-backed, not name-based
|
||||
|
||||
|
||||
def test_29b_partial_evidence_partial():
|
||||
need = _need(objective="release notes wording", affected_surface=["docs/RELEASE.md"])
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
assert app["verdict"] in (cp.PARTIAL, cp.DOES_NOT_APPLY)
|
||||
|
||||
|
||||
def test_29c_contradictory_project_evidence_does_not_apply():
|
||||
"""§3/§29C: project evidence overrides the pattern assumption."""
|
||||
need = _need(
|
||||
constraints={"contracts": [], "pattern_exclusions": ["no deployment system in this project"]},
|
||||
objective="pure algorithm work, no deployment system here",
|
||||
)
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
assert app["verdict"] == cp.DOES_NOT_APPLY
|
||||
assert "declared exclusion" in app["reason"]
|
||||
|
||||
|
||||
def test_29d_insufficient_evidence_unknown():
|
||||
p = cp.ConstructionPattern("empty", "no signals declared")
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(p, need, surface, auth)
|
||||
assert app["verdict"] == cp.UNKNOWN
|
||||
|
||||
|
||||
def test_29e_name_similarity_alone_never_applies():
|
||||
"""A pattern whose NAME matches but whose signals find no evidence stays
|
||||
UNKNOWN/DOES_NOT_APPLY — names establish nothing (§4)."""
|
||||
p = cp.ConstructionPattern(
|
||||
"deployment-thing",
|
||||
"sounds relevant",
|
||||
signals={"path_contains": ["/nonexistent/xyz/"], "objective_any": ["zzznever"]},
|
||||
)
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(p, need, surface, auth)
|
||||
assert app["verdict"] in (cp.DOES_NOT_APPLY, cp.UNKNOWN)
|
||||
|
||||
|
||||
def test_29f_model_proposal_pending():
|
||||
prop = cp.record_pattern_proposal("some-pattern", provider="local", model="m", evidence=["hunch"], confidence=0.5)
|
||||
assert prop["verification_state"] == "PENDING"
|
||||
with pytest.raises(ValueError):
|
||||
cp.record_pattern_proposal("x", provider="", model="m", evidence=[], confidence=0.5)
|
||||
|
||||
|
||||
def test_29g_project_evidence_overrides_pattern():
|
||||
"""The strongest form of §3: pattern signals say APPLIES, but the project
|
||||
records an explicit exception for the derived obligation — the obligation
|
||||
is suppressed WITH provenance, not deleted (§18)."""
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(
|
||||
RELEASE_DEPLOYMENT,
|
||||
need,
|
||||
surface,
|
||||
auth,
|
||||
project_exceptions=[
|
||||
{
|
||||
"obligation": "rel-monotonic-promotion",
|
||||
"reason": "single-runner serialized deploys documented in project policy",
|
||||
"evidence": "project policy doc",
|
||||
"authority": "user",
|
||||
}
|
||||
],
|
||||
)
|
||||
suppressed = [e for e in deriv["rejected"] if e["id"] == "rel-monotonic-promotion"]
|
||||
assert suppressed and suppressed[0]["verdict"] == "EXCEPTED"
|
||||
assert suppressed[0]["exception_evidence"] == "project policy doc"
|
||||
# the pattern's other obligations still derive
|
||||
assert any(e["id"] == "rel-fail-closed-qualification" for e in deriv["accepted"])
|
||||
|
||||
|
||||
# ── §30 obligation derivation ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_30a_applicable_pattern_derives():
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
assert deriv["accepted"]
|
||||
e = deriv["accepted"][0]
|
||||
assert e["provenance"] == "PATTERN"
|
||||
assert e["pattern_provenance"] # evidence chain carried
|
||||
|
||||
|
||||
def test_30b_non_applicable_pattern_derives_nothing():
|
||||
need = _need(objective="write a poem", affected_surface=["docs/poem.md"])
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
# no obligations accepted as REQUIRED (pattern-level always-obligations
|
||||
# only fire when the PATTERN applies — a DOES_NOT_APPLY pattern's
|
||||
# derivation is not consulted at all by make_decision)
|
||||
assert deriv["accepted"] == [] or all(e["verdict"] == "REQUIRED" for e in deriv["accepted"])
|
||||
|
||||
|
||||
def test_30d_unknown_obligation_not_silently_required():
|
||||
p = cp.ConstructionPattern(
|
||||
"weird",
|
||||
"purpose",
|
||||
obligations=[{"id": "mystery", "obligation": "do the unknown thing", "when": {"unsupported_shape": True}}],
|
||||
)
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(p, need, surface, auth)
|
||||
assert deriv["unresolved"] and deriv["unresolved"][0]["verdict"] == cp.OBL_UNKNOWN
|
||||
assert deriv["accepted"] == []
|
||||
|
||||
|
||||
def test_30e_provenance_distinct():
|
||||
"""§8: structural derivations and pattern derivations carry different
|
||||
provenance kinds in the trace."""
|
||||
need = _need()
|
||||
trace = cd.make_decision(
|
||||
need, authorities={"rune_spine": _spine()}, project_toolchain={"existing_language": "python"}
|
||||
)
|
||||
obl = trace["obligations"]
|
||||
assert "pattern" in obl and obl["pattern"]
|
||||
assert all(o["provenance"] == "PATTERN" for o in obl["pattern"])
|
||||
# structural obligations (regression etc.) remain their own kind
|
||||
assert "regression" in obl
|
||||
|
||||
|
||||
def test_30f_duplicate_obligations_collapse():
|
||||
p1 = cp.ConstructionPattern(
|
||||
"a", "pa", obligations=[{"id": "shared", "obligation": "regression proof", "when": {"always": True}}]
|
||||
)
|
||||
p2 = cp.ConstructionPattern(
|
||||
"b", "pb", obligations=[{"id": "shared", "obligation": "regression proof", "when": {"always": True}}]
|
||||
)
|
||||
d1 = cp.derive_pattern_obligations(p1, _need(), *(_surface(_need())[::-1]))
|
||||
d2 = cp.derive_pattern_obligations(p2, _need(), *(_surface(_need())[::-1]))
|
||||
combined = cp.combine_pattern_obligations([d1, d2])
|
||||
shared = [o for o in combined["obligations"] if o["id"] == "shared"]
|
||||
assert len(shared) == 1
|
||||
# first pattern's provenance kept, later duplicates recorded in also_from
|
||||
assert shared[0]["pattern"] == "a"
|
||||
assert "b" in shared[0]["also_from"]
|
||||
|
||||
|
||||
def test_30g_conflicting_obligations_surface():
|
||||
p1 = cp.ConstructionPattern(
|
||||
"x", "px", obligations=[{"id": "shared", "obligation": "version A of the rule", "when": {"always": True}}]
|
||||
)
|
||||
p2 = cp.ConstructionPattern(
|
||||
"y", "py", obligations=[{"id": "shared", "obligation": "version B of the rule", "when": {"always": True}}]
|
||||
)
|
||||
d1 = cp.derive_pattern_obligations(p1, _need(), *(_surface(_need())[::-1]))
|
||||
d2 = cp.derive_pattern_obligations(p2, _need(), *(_surface(_need())[::-1]))
|
||||
combined = cp.combine_pattern_obligations([d1, d2])
|
||||
assert combined["conflicts"]
|
||||
assert combined["conflicts"][0]["conflict"] == "same id, different text"
|
||||
|
||||
|
||||
# ── §31 pattern-specific ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_31_release_pattern_negative_and_forward():
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
ids = {e["id"] for e in deriv["accepted"]}
|
||||
assert "rel-negative-candidate-proof" in ids
|
||||
assert "rel-forward-candidate-proof" in ids
|
||||
assert "rel-deployed-identity-verification" in ids
|
||||
|
||||
|
||||
def test_31_ui_pattern_layout_inheritance():
|
||||
scaffold = ss.new_doc()
|
||||
from rune import layout_contract as lc
|
||||
|
||||
lc.define_region(scaffold, "footer-bar", provenance="scaffold")
|
||||
lc.bind_component(scaffold, "version_display", "footer-bar", provenance="user", lock=True, order=0)
|
||||
need = cd.Need(
|
||||
"u",
|
||||
"adjust the tui version widget",
|
||||
affected_surface=["ui:version_display"],
|
||||
constraints={},
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
authorities = {"rune_spine": ss.new_doc(), "project_scaffold": scaffold}
|
||||
surface = cd.resolve_affected_surface(need, authorities)
|
||||
from rune.construction_patterns_builtin import UI_TUI_STRUCTURAL
|
||||
|
||||
app = cp.evaluate_applicability(UI_TUI_STRUCTURAL, need, surface, authorities)
|
||||
# ui-surface + entity signals hit; path signal misses (no source paths
|
||||
# in this fixture) — honest PARTIAL, obligations still derive
|
||||
assert app["verdict"] in (cp.APPLIES, cp.PARTIAL)
|
||||
deriv = cp.derive_pattern_obligations(UI_TUI_STRUCTURAL, need, surface, authorities)
|
||||
ids = {e["id"] for e in deriv["accepted"]}
|
||||
assert "ui-layout-contract-inherited" in ids
|
||||
assert "ui-user-locked-preserved" in ids
|
||||
# and the actual layout obligation confirms the LOCK
|
||||
lo = [o for o in cd.layout_obligations_for(scaffold, ["ui:version_display"])]
|
||||
assert lo and lo[0]["lock"] is True
|
||||
|
||||
|
||||
def test_31_store_pattern_backward_readability():
|
||||
from rune.construction_patterns_builtin import STORE_SCHEMA_EVOLUTION
|
||||
|
||||
need = cd.Need(
|
||||
"s",
|
||||
"add expected effects to the scaffold store",
|
||||
affected_surface=["src/rune/scaffold.py"],
|
||||
constraints={},
|
||||
evidence_refs=["x"],
|
||||
approval_context="c",
|
||||
)
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(STORE_SCHEMA_EVOLUTION, need, surface, auth)
|
||||
assert app["verdict"] in (cp.APPLIES, cp.PARTIAL)
|
||||
deriv = cp.derive_pattern_obligations(STORE_SCHEMA_EVOLUTION, need, surface, auth)
|
||||
ids = {e["id"] for e in deriv["accepted"]}
|
||||
assert "store-backward-readability" in ids
|
||||
|
||||
|
||||
# ── §32 process guards ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_32a_durable_mutation_reload_disagrees(tmp_path):
|
||||
p = tmp_path / "store.json"
|
||||
p.write_text(json.dumps({"flag": False}))
|
||||
doc = json.loads(p.read_text())
|
||||
doc["flag"] = True # in-memory mutation...
|
||||
# ...never saved: the guard must catch the lie
|
||||
assert not cg.verify_persisted(p, lambda d: d["flag"], True)
|
||||
with pytest.raises(AssertionError):
|
||||
cg.assert_persisted(p, lambda d: d["flag"], True, what="flag")
|
||||
|
||||
|
||||
def test_32b_durable_mutation_saved_reload_matches(tmp_path):
|
||||
p = tmp_path / "store.json"
|
||||
p.write_text(json.dumps({"flag": False}))
|
||||
doc = json.loads(p.read_text())
|
||||
doc["flag"] = True
|
||||
p.write_text(json.dumps(doc)) # actually saved
|
||||
assert cg.verify_persisted(p, lambda d: d["flag"], True)
|
||||
cg.assert_persisted(p, lambda d: d["flag"], True, what="flag")
|
||||
|
||||
|
||||
def test_32c_zero_match_edit_is_noop_not_success(tmp_path):
|
||||
with pytest.raises(cg.EditNoOp):
|
||||
cg.apply_source_edit("nothing matches here", "ABSENT TEXT", "new")
|
||||
# intentional no-op is representable truthfully (§32E)
|
||||
result = cg.apply_source_edit("nothing matches here", "ABSENT TEXT", "new", allow_noop=True)
|
||||
assert result == "nothing matches here"
|
||||
|
||||
|
||||
def test_32d_edit_postcondition_absent_fails():
|
||||
with pytest.raises(cg.EditPostconditionFailed):
|
||||
cg.apply_source_edit("alpha beta", "alpha", "gamma", postcondition="delta")
|
||||
|
||||
|
||||
def test_32e_file_edit_with_readback(tmp_path):
|
||||
p = tmp_path / "mod.py"
|
||||
p.write_text("def old_fn():\n pass\n")
|
||||
n = cg.apply_source_edit_file(p, "old_fn", "new_fn", expected_count=1, postcondition="def new_fn():")
|
||||
assert n == 1
|
||||
assert "new_fn" in p.read_text()
|
||||
# and the readback guard ran inside apply_source_edit_file
|
||||
|
||||
|
||||
# ── §25 HELD-OUT: pattern must not overfit its origin incident ──────────────
|
||||
|
||||
|
||||
def test_25heldout_release_config_change_not_demanding_promotion_rework():
|
||||
"""Held-out release case: a deployment CONFIGURATION change that does not
|
||||
touch promotion ordering. The release pattern must NOT demand
|
||||
monotonic-promotion implementation change, but SHOULD still require
|
||||
release regression verification (release-level obligation)."""
|
||||
need = cd.Need(
|
||||
"heldout",
|
||||
"change the deploy notification webhook URL",
|
||||
affected_surface=[".gitea/workflows/deploy.yml"],
|
||||
constraints={},
|
||||
evidence_refs=["x"],
|
||||
approval_context="c",
|
||||
)
|
||||
authorities = {"rune_spine": _spine()}
|
||||
surface = cd.resolve_affected_surface(need, authorities)
|
||||
app = cp.evaluate_applicability(RELEASE_DEPLOYMENT, need, surface, authorities)
|
||||
# workflow-config surface hit → the pattern applies to the release class
|
||||
assert app["verdict"] in (cp.APPLIES, cp.PARTIAL)
|
||||
deriv = cp.derive_pattern_obligations(RELEASE_DEPLOYMENT, need, surface, authorities)
|
||||
accepted = {e["id"] for e in deriv["accepted"]}
|
||||
rejected = {e["id"] for e in deriv["rejected"]}
|
||||
# promotion-ordering work is NOT applicable to a webhook URL change
|
||||
assert "rel-monotonic-promotion" in rejected
|
||||
# but release-level invariants still apply
|
||||
assert "rel-release-regression-verification" in accepted
|
||||
assert "rel-deployed-identity-verification" in accepted
|
||||
|
||||
|
||||
# ── §24 LIVE CHANGE: save_verified at the durable boundary ──────────────────
|
||||
|
||||
def test_24live_save_verified(tmp_path):
|
||||
"""The pattern-governed live change: Scaffold.save_verified composes
|
||||
save + reload-readback. The UNSAVED-mutation lie fails; the saved truth
|
||||
passes. (Pattern contribution proven separately: store-roundtrip
|
||||
obligation derived by store-schema-evolution.)"""
|
||||
from rune import scaffold as sc # local alias for the live test
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "lv")
|
||||
doc = s.init(purpose="live")
|
||||
s.new_contract(doc, contract_id="LV1", need="n", scope=["src/x"])
|
||||
s.save(doc) # contract IS persisted at this point
|
||||
# the T5 defect shape: mutate in memory, NEVER save, claim persisted —
|
||||
# verify_on_disk (pure readback, no write) catches the lie
|
||||
c = s.contract(doc, "LV1")
|
||||
c["identities"]["next_action"] = "tampered claim"
|
||||
with pytest.raises(AssertionError, match="probably never saved"):
|
||||
s.verify_on_disk(lambda d: s.contract(d, "LV1")["identities"][
|
||||
"next_action"], "tampered claim", what="LV1 next_action")
|
||||
# save-and-confirm catches disk-write failures (different semantic)
|
||||
s.transition_contract(doc, "LV1", "approved")
|
||||
s.save_verified(doc, lambda d: s.contract(d, "LV1")["phase"], "approved",
|
||||
what="LV1 phase")
|
||||
# and the forgotten-save check now passes — the state is really durable
|
||||
s.verify_on_disk(lambda d: s.contract(d, "LV1")["phase"], "approved",
|
||||
what="LV1 phase")
|
||||
# and a fresh instance sees it (the actual continuity requirement)
|
||||
doc2 = sc.Scaffold(tmp_path / ".rune" / "scaffold", "lv").load()
|
||||
assert sc.Scaffold(tmp_path / ".rune" / "scaffold", "lv").contract(
|
||||
doc2, "LV1")["phase"] == "approved"
|
||||
@@ -385,7 +385,8 @@ def test_thread_safety_concurrent_assemble():
|
||||
|
||||
def test_operator_characterization_parity():
|
||||
"""Existing role-message assertions still hold via the broker path."""
|
||||
operator = rune_operator.RuneOperator("/tmp/context-broker-parity-operator.json")
|
||||
import tempfile
|
||||
operator = rune_operator.RuneOperator(str(Path(tempfile.mkdtemp()) / "parity-operator.json"))
|
||||
runtime = Mock()
|
||||
runtime.run.return_value = OperatorResponse(success=True, user_text="Final answer", model="qwen3:8b")
|
||||
profile = SimpleNamespace(safety_tier=2)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""test_contract_quality.py — SCAFFOLD-TRANCHE-004 §30 test plan (A-H).
|
||||
|
||||
Repaired closure semantics: expected-new vs genuinely-missed; independent
|
||||
observed confirmation; prediction may not self-certify.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
|
||||
|
||||
def _s(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "proj")
|
||||
doc = s.init(purpose="quality tests")
|
||||
return s, doc
|
||||
|
||||
|
||||
def _mk(s, doc, cid, affected=None, expected_new=None):
|
||||
s.new_contract(
|
||||
doc,
|
||||
contract_id=cid,
|
||||
need="n",
|
||||
scope=["src/x"],
|
||||
affected_entities=affected or [],
|
||||
expected_new_entities=expected_new or [],
|
||||
)
|
||||
s.transition_contract(doc, cid, "approved")
|
||||
s.transition_contract(doc, cid, "building")
|
||||
s.transition_contract(doc, cid, "verifying")
|
||||
return cid
|
||||
|
||||
|
||||
def _close(s, doc, cid, observed):
|
||||
s.close_contract(doc, cid, observed_evidence=observed)
|
||||
return s.contract(doc, cid)["ext"]["contract_quality"]
|
||||
|
||||
|
||||
# A. expected-new entity observed → NOT missed
|
||||
def test_a_expected_new_observed_not_missed(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:old"], expected_new=["module:fresh"])
|
||||
q = _close(
|
||||
s,
|
||||
doc,
|
||||
"C1",
|
||||
[{"entity": "module:fresh", "source": "file-added"}, {"entity": "module:old", "source": "git-diff"}],
|
||||
)
|
||||
assert "module:fresh" in q["expected_new_observed"]
|
||||
assert "module:fresh" not in q["genuinely_unexpected_new"]
|
||||
assert "module:fresh" not in q["missed"] # legacy alias agrees
|
||||
|
||||
|
||||
# B. expected-new absent → explicit expected-new-missing
|
||||
def test_b_expected_new_missing(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=[], expected_new=["module:promised"])
|
||||
q = _close(s, doc, "C1", [])
|
||||
assert q["expected_new_missing"] == ["module:promised"]
|
||||
assert q["expected_new_observed"] == []
|
||||
|
||||
|
||||
# C. truly unexpected new entity → unexpected category + CANDIDATE
|
||||
def test_c_unexpected_new(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a"], expected_new=[])
|
||||
q = _close(
|
||||
s,
|
||||
doc,
|
||||
"C1",
|
||||
[{"entity": "module:a", "source": "git-diff"}, {"entity": "module:surprise", "source": "git-diff"}],
|
||||
)
|
||||
assert q["genuinely_unexpected_new"] == ["module:surprise"]
|
||||
assert q["candidate_corrections"][0]["entity"] == "module:surprise"
|
||||
|
||||
|
||||
# D. predicted existing independently observed → correctly predicted
|
||||
def test_d_predicted_existing_confirmed(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a", "module:b"])
|
||||
q = _close(
|
||||
s, doc, "C1", [{"entity": "module:a", "source": "git-diff"}, {"entity": "module:b", "source": "test-run"}]
|
||||
)
|
||||
assert set(q["correctly_predicted"]) == {"module:a", "module:b"}
|
||||
assert q["predicted_but_unaffected"] == []
|
||||
|
||||
|
||||
# E. predicted existing not observed → predicted-but-unaffected (NOT needless)
|
||||
def test_e_predicted_but_unaffected(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a", "module:untouched"])
|
||||
q = _close(s, doc, "C1", [{"entity": "module:a", "source": "git-diff"}])
|
||||
assert q["predicted_but_unaffected"] == ["module:untouched"]
|
||||
assert q["correctly_predicted"] == ["module:a"]
|
||||
# legacy alias preserved
|
||||
assert q["needless"] == ["module:untouched"]
|
||||
|
||||
|
||||
# F. prediction may not self-certify observation
|
||||
def test_f_no_self_certification(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a", "module:b"])
|
||||
# observe ONLY module:a — module:b must NOT auto-confirm from prediction
|
||||
q = _close(s, doc, "C1", [{"entity": "module:a", "source": "git-diff"}])
|
||||
assert "module:b" not in q["correctly_predicted"]
|
||||
assert q["predicted_but_unaffected"] == ["module:b"]
|
||||
|
||||
|
||||
# G. closure evidence remains independently sourced (sources recorded)
|
||||
def test_g_evidence_sources_recorded(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a"])
|
||||
q = _close(
|
||||
s, doc, "C1", [{"entity": "module:a", "source": "git-diff"}, {"entity": "module:a", "source": "test-run"}]
|
||||
)
|
||||
assert set(q["evidence_sources"]) == {"git-diff", "test-run"}
|
||||
obs_kinds = [o["kind"] for o in doc["observations"]]
|
||||
assert obs_kinds.count("observed_consequence") == 2
|
||||
|
||||
|
||||
# H. old Construction Contracts remain readable
|
||||
def test_h_legacy_contract_still_closes(tmp_path):
|
||||
"""A Tranche-2-style contract (no expected_new_entities field) closes
|
||||
with the repaired semantics without migration."""
|
||||
s, doc = _s(tmp_path)
|
||||
s.new_contract(doc, contract_id="OLD-1", need="old", scope=["src/x"], affected_entities=["module:a"])
|
||||
c = s.contract(doc, "OLD-1")
|
||||
assert c["identities"].get("expected_new_entities") == [] # empty, fine
|
||||
s.transition_contract(doc, "OLD-1", "approved")
|
||||
s.transition_contract(doc, "OLD-1", "building")
|
||||
s.transition_contract(doc, "OLD-1", "verifying")
|
||||
q = _close(s, doc, "OLD-1", [{"entity": "module:a", "source": "git-diff"}])
|
||||
assert q["correctly_predicted"] == ["module:a"]
|
||||
assert q["expected_new_entities"] == []
|
||||
assert q["genuinely_unexpected_new"] == []
|
||||
|
||||
|
||||
def test_record_observed_impact_rejects_unattributed_gently(tmp_path):
|
||||
"""Evidence without entity ids is tolerated (counted) but never
|
||||
fabricates observed entities."""
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a"])
|
||||
q = _close(s, doc, "C1", [{"detail": "vague note", "source": "chat"}])
|
||||
assert q["correctly_predicted"] == []
|
||||
assert q["predicted_but_unaffected"] == ["module:a"]
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user