Compare commits
395
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9835261517 | ||
|
|
3788875410 | ||
|
|
d96b89bd4c | ||
|
|
fe45c6a8dd | ||
|
|
c7b2edd4fc | ||
|
|
96f0e7e263 | ||
|
|
a168cd935e | ||
|
|
a2c4a11b0d | ||
|
|
16164c6f5e | ||
|
|
0722dc2339 | ||
|
|
a7ec1b49d7 | ||
|
|
5b61f8a8bb | ||
|
|
c730719768 | ||
|
|
4fe0f8ee46 | ||
|
|
8999f29041 | ||
|
|
c288f4726d | ||
|
|
308bbb74b7 | ||
|
|
1f01e87453 | ||
|
|
4e8e081f48 | ||
|
|
6c681b97bf | ||
|
|
d01d6eb6d4 | ||
|
|
f90695197d | ||
|
|
279c2b86cb | ||
|
|
57206b7b31 | ||
|
|
b69ffcd44f | ||
|
|
c4b4ebd0af | ||
|
|
4220621cb0 | ||
|
|
4b54451444 | ||
|
|
962e3511e1 | ||
|
|
fb809135c6 | ||
|
|
10acdfe708 | ||
|
|
fd9e1bffb8 | ||
|
|
631426763e | ||
|
|
09cd18ad54 | ||
|
|
da500df487 | ||
|
|
51416aa95e | ||
|
|
b68f31ff0d | ||
|
|
dd41f15571 | ||
|
|
57e4b8672c | ||
|
|
6923128f45 | ||
|
|
9db239fcb4 | ||
|
|
658ec63627 | ||
|
|
427c3ac6bb | ||
|
|
509ec00c87 | ||
|
|
bea523a0e3 | ||
|
|
0a15a1675d | ||
|
|
81c6d1f9c6 | ||
|
|
d53bb9a535 | ||
|
|
023151acb9 | ||
|
|
2da9305d96 | ||
|
|
2e05ea6848 | ||
|
|
be161c2043 | ||
|
|
a6539986b9 | ||
|
|
8da216d155 | ||
|
|
05f2a74c6f | ||
|
|
1c5dc557a6 | ||
|
|
a6bccc1c5d | ||
|
|
f208b5e61c | ||
|
|
aa800acbad | ||
|
|
30be2c96b8 | ||
|
|
ca00be8259 | ||
|
|
50a63e587f | ||
|
|
a54ab559cb | ||
|
|
599c3ab1c2 | ||
|
|
483bfd7944 | ||
|
|
1650b470c6 | ||
|
|
853cf3e0ae | ||
|
|
875bb792f7 | ||
|
|
f06ddb3a20 | ||
|
|
2ebd9da139 | ||
|
|
65001196d8 | ||
|
|
76bb2b97be | ||
|
|
e765f654f9 | ||
|
|
f7141200bd | ||
|
|
53b4830b08 | ||
|
|
fa1b7c4be1 | ||
|
|
246f26663d | ||
|
|
5718a3ea85 | ||
|
|
02802f321f | ||
|
|
4ce121a493 | ||
|
|
8a8d3534c6 | ||
|
|
d0ec37925f | ||
|
|
f9936fa244 | ||
|
|
04a277f22c | ||
|
|
b2777d666f | ||
|
|
0982f7317f | ||
|
|
9583678997 | ||
|
|
5db8b202d9 | ||
|
|
78c0d97a43 | ||
|
|
551530c78c | ||
|
|
b669e4d4ed | ||
|
|
2b6a5a6b95 | ||
|
|
e2e7b74a8e | ||
|
|
db4ce7f6a6 | ||
|
|
505209b839 | ||
|
|
f7256aa29e | ||
|
|
26e3765319 | ||
|
|
e6451a5afc | ||
|
|
7008743620 | ||
|
|
f545f1ed8d | ||
|
|
73803e6a0d | ||
|
|
ed1696606e | ||
|
|
4944f9e41c | ||
|
|
6722066c79 | ||
|
|
df8262b9ed | ||
|
|
756ebe6201 | ||
|
|
f64ee95aad | ||
|
|
c3a0a48ca5 | ||
|
|
b4ed258121 | ||
|
|
bacea2d472 | ||
|
|
9acfc0953e | ||
|
|
818aa49043 | ||
|
|
212239ca7b | ||
|
|
ae6d4a0d98 | ||
|
|
00013f8c0b | ||
|
|
ba2966e631 | ||
|
|
20fcee3f40 | ||
|
|
493d9c2800 | ||
|
|
6b5d8ad023 | ||
|
|
f3ef4672f3 | ||
|
|
be6bc17537 | ||
|
|
40b3731928 | ||
|
|
caa77f8a35 | ||
|
|
4d215ea666 | ||
|
|
fda9b6dda0 | ||
|
|
116d1ffbeb | ||
|
|
040988cf00 | ||
|
|
ffed816465 | ||
|
|
72ab7dccf7 | ||
|
|
5d20dc7f51 | ||
|
|
9e903dacaf | ||
|
|
ed1b41abd5 | ||
|
|
ff1b950f11 | ||
|
|
3afc9ff872 | ||
|
|
28e9c8c1c1 | ||
|
|
8249854881 | ||
|
|
4aeef171d2 | ||
|
|
2ccac89ec4 | ||
|
|
232986f0c3 | ||
|
|
725c779ac7 | ||
|
|
edd6d06300 | ||
|
|
98cb3a98a5 | ||
|
|
7881e78e7f | ||
|
|
57a689d22f | ||
|
|
45869a77cd | ||
|
|
224fbfa4e9 | ||
|
|
417e824a51 | ||
|
|
ca2807e9b5 | ||
|
|
25f5219982 | ||
|
|
2d09bee39e | ||
|
|
77bc2161a6 | ||
|
|
74e18402f0 | ||
|
|
39ec00b361 | ||
|
|
95946b6efd | ||
|
|
72d3c909c8 | ||
|
|
ab2dfe7b09 | ||
|
|
1326713234 | ||
|
|
867321ed05 | ||
|
|
a44ced163e | ||
|
|
f40f7e7233 | ||
|
|
8bef4682f5 | ||
|
|
f939d9db5a | ||
|
|
dcead3fecc | ||
|
|
87654cbfd1 | ||
|
|
6dddb0a5ee | ||
|
|
f90725dba8 | ||
|
|
24b7dda24f | ||
|
|
c7c6b2d5ac | ||
|
|
6a761f2d87 | ||
|
|
9ef267f2e3 | ||
|
|
bd80fb0e3b | ||
|
|
759a836726 | ||
|
|
71f051d928 | ||
|
|
2b1d0ac245 | ||
|
|
fbb5018413 | ||
|
|
c8f0a8e353 | ||
|
|
fb7fb34ca5 | ||
|
|
abe24da81d | ||
|
|
15c54539b5 | ||
|
|
ad35a2f7f5 | ||
|
|
e2f536f71a | ||
|
|
5b2712f5e6 | ||
|
|
124b77e95b | ||
|
|
1b7995e093 | ||
|
|
fcd835883e | ||
|
|
37ce68eff8 | ||
|
|
2fa0a5f144 | ||
|
|
c3ef29c59d | ||
|
|
f079aa2cc9 | ||
|
|
c03c953a45 | ||
|
|
90ae1c2612 | ||
|
|
cb1b8303fe | ||
|
|
3c54782634 | ||
|
|
478ce45755 | ||
|
|
a96ed90b90 | ||
|
|
713f85a0e3 | ||
|
|
504935828b | ||
|
|
8e78bc936f | ||
|
|
a34762d512 | ||
|
|
772402ed39 | ||
|
|
80a1fd0a2d | ||
|
|
3fb28e2461 | ||
|
|
94ba2c97a3 | ||
|
|
94a4774853 | ||
|
|
efb2c6b9a1 | ||
|
|
99115889d7 | ||
|
|
2a971578e1 | ||
|
|
ec434f2a70 | ||
|
|
d9c50c66c9 | ||
|
|
a382c15562 | ||
|
|
186fdd1410 | ||
|
|
aa9750c59f | ||
|
|
ab114d260b | ||
|
|
b7865a76bb | ||
|
|
04b0f4ac96 | ||
|
|
9b328c9af1 | ||
|
|
bc0f244934 | ||
|
|
29570f404a | ||
|
|
927548e9a0 | ||
|
|
781b286f5b | ||
|
|
d1ccd7ebec | ||
|
|
3adfa1b1ed | ||
|
|
da4eece9e0 | ||
|
|
2e1f0c8cab | ||
|
|
6e2f5950bd | ||
|
|
c53c82bb35 | ||
|
|
35adcd8cb1 | ||
|
|
810d0027c3 | ||
|
|
4839a62b70 | ||
|
|
15408841ac | ||
|
|
c5c69de06f | ||
|
|
fcde0b2ce3 | ||
|
|
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 |
No files matched your search
@@ -1,16 +1,45 @@
|
||||
---
|
||||
name: Bug Fix
|
||||
about: Fix a bug or issue
|
||||
about: Demonstrate currently incorrect behaviour — plain English first, proof required
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## Plain summary
|
||||
<!-- 2-4 sentences, NON-TECHNICAL: what's broken and why it matters. -->
|
||||
## In plain English: <restate the issue title here in everyday words>
|
||||
<!-- The heading ITSELF must carry the summary — a scan of issue headings
|
||||
alone should tell the whole story of the board. TOP PRIORITY: if a
|
||||
technical detail below contradicts this section, the issue is buggy —
|
||||
fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
## Example
|
||||
<!-- A concrete before/after: "Doing X currently produces Y; it should produce Z." -->
|
||||
|
||||
What is broken: <what a person observes going wrong>
|
||||
|
||||
What should happen instead: <the correct behaviour>
|
||||
|
||||
How often / when: <always | after X | since change Y>
|
||||
|
||||
## Proof it is broken (required — no proof, no bug)
|
||||
<!-- A bug is DEMONSTRATED currently incorrect behaviour. Nothing else.
|
||||
If this section cannot be filled, file it as a question, not a bug. -->
|
||||
- [ ] Reproduction command or steps (deterministic where possible)
|
||||
- [ ] The exact wrong output observed: <paste/log excerpt>
|
||||
- [ ] Where the evidence lives: <log path, fixture, session id>
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
## What would let us close this
|
||||
- [ ] The reproduction command now shows correct behaviour
|
||||
- [ ] A regression test exists that fails before the fix and passes after
|
||||
- [ ] No other behaviour changed (or changes are listed and justified)
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- A bug fix is USUALLY a patch. Pick major or minor only with a reason. -->
|
||||
- [ ] patch — same behaviour, done correctly (usual for bugs)
|
||||
- [ ] major — the bug was in a published contract and fixing it breaks consumers (justify)
|
||||
- [ ] minor — fixing revealed a missing capability that had to be added (justify)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT (the bug) / WHERE (files) / EVIDENCE (how it was confirmed). -->
|
||||
@@ -29,10 +58,6 @@ assignees: conductor
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit references
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: New feature or improvement — DESCRIPTION GATE v4 format (plain summary + pipeline requirements)
|
||||
about: New feature or improvement — plain English first, verifiable close conditions, version intent
|
||||
title: "[FEATURE] "
|
||||
labels: feature
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## Plain summary
|
||||
<!-- 2-4 sentences, NON-TECHNICAL: what this does and why it matters. Nick should
|
||||
understand the goal without decoding internals. -->
|
||||
## In plain English: <restate the issue title here in everyday words>
|
||||
<!-- The heading ITSELF must carry the summary — a scan of issue headings
|
||||
alone should tell the whole story of the board. TOP PRIORITY: if a
|
||||
technical detail below contradicts this section, the issue is buggy —
|
||||
fix the issue. A non-technical reader must be able to retell this
|
||||
section. 3-6 sentences, no jargon. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is this about: <what>
|
||||
|
||||
Why do we need it: <the itch — what happens today that shouldn't, or what becomes possible>
|
||||
|
||||
Why is it still open: <"not started yet" | "part done, X remains" | "deliberately parked as a future idea">
|
||||
|
||||
## Example
|
||||
<!-- A concrete example of the intended behavior, in plain terms:
|
||||
"If X happens, the system will Y instead of Z." -->
|
||||
<!-- A concrete before/after in plain terms: "If X happens, the system will Y instead of Z." -->
|
||||
|
||||
## What would let us close this
|
||||
<!-- Checkable statements, verifiable by looking at something that EXISTS —
|
||||
a file, a log line, a passing test. Not "when appropriate". -->
|
||||
- [ ] <condition 1 — observable>
|
||||
- [ ] <condition 2>
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- The pipeline derives the real bump from merged conventional commits and
|
||||
compares against this declaration. A mismatch is flagged, not fatal:
|
||||
either the declaration was wrong or the work grew. Both facts matter. -->
|
||||
- [ ] major — breaking: changes a contract, schema, or interface others depend on
|
||||
- [ ] minor — new capability: something becomes possible that wasn't
|
||||
- [ ] patch — repair: same behaviour, done correctly
|
||||
- [ ] none — touches no released artifact (docs, memory, process notes)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT — one-line objective.
|
||||
@@ -37,7 +62,6 @@ DEPENDENCIES — parent epic + preceding stage/plan refs. -->
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit/PR references
|
||||
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
@@ -69,4 +93,4 @@ Hard invariants: derived cognition preserves evidentiary ancestry · epistemic c
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: Research Submission
|
||||
about: Buy down uncertainty before building — question, falsifier, budget, durable findings
|
||||
title: "[RESEARCH] "
|
||||
labels: research
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## In plain English: <restate the issue title here in everyday words>
|
||||
<!-- The heading ITSELF must carry the summary — a scan of issue headings
|
||||
alone should tell the whole story of the board. TOP PRIORITY: if a
|
||||
technical detail below contradicts this section, the issue is buggy —
|
||||
fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
The question: <what we're trying to learn, one sentence>
|
||||
|
||||
Why we're asking: <what decision this research will inform — a name, an issue
|
||||
number, or "exploratory: none yet">
|
||||
|
||||
What we believe today: <the current assumption, stated so it can be proven wrong>
|
||||
|
||||
## The shape of an answer
|
||||
- [ ] What evidence would settle the question: <observation, benchmark, or spike>
|
||||
- [ ] What evidence would KILL the current assumption (the falsifier): <be specific —
|
||||
this is stated BEFORE starting so the research cannot become a search for confirmation>
|
||||
- [ ] Budget: <time/effort cap — when research must stop and report back>
|
||||
|
||||
## What would let us close this
|
||||
- [ ] Findings written to a durable document: <path, e.g. research/issue-NNN/> —
|
||||
a comment thread is not a durable finding
|
||||
- [ ] The decision the research informs is recorded: made / deferred / changed
|
||||
- [ ] If a follow-up build issue was minted: <#N or "none needed">
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
- [ ] none — research changes no released artifact (usual)
|
||||
- [ ] minor/major — only if a code spike is being merged as part of this (justify)
|
||||
|
||||
## Technical detail
|
||||
<!-- Candidate approaches, prior art in the repo, related issues, constraints,
|
||||
rebuildable-projection or authority requirements if findings touch canonical state. -->
|
||||
|
||||
## Pipeline requirements
|
||||
- [ ] Findings committed under the stated durable path
|
||||
- [ ] Every citation verified against a real source (no fabricated references)
|
||||
- [ ] Closed with a properly formatted closing comment: findings summary + decision
|
||||
|
||||
## Cognitive invariants (memory/cognition research — both handoffs)
|
||||
<!-- Complete ONLY if findings touch memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before proposing modules)
|
||||
- [ ] **Read/write authority** — which canonical state is affected
|
||||
- [ ] **Provenance** — findings traceable to evidence
|
||||
- [ ] **Reversibility** — projections remain rebuildable; no unique truth moves into a derived index
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · retrieval never increases utility · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — blocks a decision that blocks other work
|
||||
- [ ] **P1 — high** — informs a next-stage dependency
|
||||
- [ ] **P2 — medium** — quality/consolidation understanding
|
||||
- [ ] **P3 — low** — watchlist / deferred / exploratory
|
||||
@@ -58,9 +58,40 @@ jobs:
|
||||
const managedLabels = new Set([
|
||||
'memory', 'governance', 'identity', 'growth',
|
||||
'perception', 'infrastructure', 'life', 'custodian',
|
||||
'priority: high', 'bug', 'feature',
|
||||
'priority: high', 'bug', 'feature', 'research',
|
||||
]);
|
||||
|
||||
// Type label: DECLARED over GUESSED. The template spine
|
||||
// (kind: bug|feature|research) is authoritative. Keyword
|
||||
// heuristics are a fallback for issues filed before the
|
||||
// template system existed — they misclassify honest prose
|
||||
// ("what is broken", "the fix") as bugs (T15 audit finding).
|
||||
let kind = null;
|
||||
const spineMatch = body && body.match(/^kind:\s*(bug|feature|research)\s*$/m);
|
||||
if (spineMatch) {
|
||||
kind = spineMatch[1];
|
||||
}
|
||||
let isBug = false, isFeature = false, isResearch = false;
|
||||
if (kind) {
|
||||
isBug = kind === 'bug';
|
||||
isFeature = kind === 'feature';
|
||||
isResearch = kind === 'research';
|
||||
} else {
|
||||
const textBug = title.startsWith('[BUG]') || text.includes('bug') || text.includes('fix') || text.includes('broken');
|
||||
isBug = textBug;
|
||||
isFeature = !isBug && (title.startsWith('[FEATURE]') || text.includes('feature') || text.includes('improve') || text.includes('add'));
|
||||
isResearch = !isBug && !isFeature && title.startsWith('[RESEARCH]');
|
||||
}
|
||||
if (isBug) {
|
||||
desiredLabels.add('bug');
|
||||
}
|
||||
if (isFeature) {
|
||||
desiredLabels.add('feature');
|
||||
}
|
||||
if (isResearch) {
|
||||
desiredLabels.add('research');
|
||||
}
|
||||
|
||||
// Umbrella labels
|
||||
if (text.includes('memory') || text.includes('store') || text.includes('recall') || text.includes('consolidat')) {
|
||||
desiredLabels.add('memory');
|
||||
@@ -92,16 +123,6 @@ jobs:
|
||||
desiredLabels.add('priority: high');
|
||||
}
|
||||
|
||||
// Type labels
|
||||
const isBug = title.startsWith('[BUG]') || text.includes('bug') || text.includes('fix') || text.includes('broken');
|
||||
const isFeature = !isBug && (title.startsWith('[FEATURE]') || text.includes('feature') || text.includes('improve') || text.includes('add'));
|
||||
if (isBug) {
|
||||
desiredLabels.add('bug');
|
||||
}
|
||||
if (isFeature) {
|
||||
desiredLabels.add('feature');
|
||||
}
|
||||
|
||||
const issueNumber = context.payload.issue?.number || context.payload.pull_request?.number;
|
||||
const current = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -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.23**
|
||||
**Current release: v0.87.0**
|
||||
|
||||
**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.**
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""completion_file.py — REAL_ASYNC_BRIDGE: durable completion-file delivery
|
||||
with inotify wake. Replaces FIFO+lease for process-wait delivery.
|
||||
|
||||
DESIGN:
|
||||
Producer (bridge/daemon/notify):
|
||||
1. Validates operation identity
|
||||
2. Atomically writes a completion file:
|
||||
resumes/<operation_id>.done
|
||||
containing the event/verdict JSON (write to tmp, rename for atomicity)
|
||||
|
||||
Waiter (ci_event_wait / any continuation consumer):
|
||||
1. Checks if the completion file already exists (event-before-waiter)
|
||||
2. If not: registers inotify watch on the resumes/ directory
|
||||
3. Re-checks after watch registration (closes the registration race)
|
||||
4. Blocks in kernel on inotify — zero CPU, zero timer
|
||||
5. On file-created event: reads the file, verifies authority, returns
|
||||
|
||||
The event is a WAKE SIGNAL. Authority still decides terminal truth.
|
||||
The completion file is durable — survives daemon and waiter restarts.
|
||||
|
||||
WHY NOT FIFO:
|
||||
FIFO is one-shot (token consumed), requires lease management, and
|
||||
fails under session-busy guards. A completion file is atomic, durable,
|
||||
and works across restarts. inotify wakes the process with zero CPU
|
||||
cost and zero latency between file creation and wake.
|
||||
|
||||
WHY NOT POLLING:
|
||||
Polling introduces an arbitrary interval. inotify is a kernel callback
|
||||
that fires exactly when the filesystem changes — the latency tracks
|
||||
the real completion event, not a configured interval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _wr_bridge() -> Path:
|
||||
# WR_ROOT = daemon/infra convention; RUNE_WR_ROOT = repo/scripts
|
||||
# convention. Both honored (WR_ROOT wins) so the daemon, the waiter,
|
||||
# and any isolated test root always resolve the SAME directory.
|
||||
root = os.environ.get("WR_ROOT") or os.environ.get("RUNE_WR_ROOT") or "/mnt/workdrive/wr-bridge"
|
||||
return Path(root)
|
||||
|
||||
|
||||
def _resumes_dir() -> Path:
|
||||
return _wr_bridge() / "resumes"
|
||||
|
||||
|
||||
# inotify constants (Linux)
|
||||
IN_CREATE = 0x100
|
||||
IN_CLOSE_WRITE = 0x8
|
||||
IN_MOVED_TO = 0x80
|
||||
|
||||
|
||||
def completion_path(operation_id: str) -> Path:
|
||||
return _resumes_dir() / f"{operation_id}.done"
|
||||
|
||||
|
||||
def write_completion(operation_id: str, event: dict) -> Path:
|
||||
"""Atomically write a durable completion file. The rename(2) ensures
|
||||
the waiter never sees a partial file."""
|
||||
_resumes_dir().mkdir(parents=True, exist_ok=True)
|
||||
target = completion_path(operation_id)
|
||||
tmp = target.parent / f".{target.name}.tmp.{os.getpid()}"
|
||||
tmp.write_text(json.dumps(event, sort_keys=True) + "\n")
|
||||
os.rename(str(tmp), str(target)) # atomic on same filesystem
|
||||
os.chmod(str(target), 0o666) # cross-user bridge
|
||||
return target
|
||||
|
||||
|
||||
def read_completion(operation_id: str) -> dict | None:
|
||||
"""Read a durable completion file. Returns None if absent."""
|
||||
p = completion_path(operation_id)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def completion_exists(operation_id: str) -> bool:
|
||||
return completion_path(operation_id).is_file()
|
||||
|
||||
|
||||
def _inotify_init_and_add_watch(directory: str) -> int:
|
||||
"""Create an inotify instance and add a watch on the directory.
|
||||
Returns the inotify fd."""
|
||||
libc = __import__("ctypes").CDLL(None, use_errno=True)
|
||||
fd = libc.inotify_init()
|
||||
if fd < 0:
|
||||
raise OSError("inotify_init failed")
|
||||
# IN_CREATE | IN_MOVED_TO: catch both direct creation and atomic rename
|
||||
mask = IN_CREATE | IN_MOVED_TO
|
||||
wd = libc.inotify_add_watch(fd, directory.encode(), mask)
|
||||
if wd < 0:
|
||||
os.close(fd)
|
||||
raise OSError(f"inotify_add_watch failed for {directory}")
|
||||
return fd
|
||||
|
||||
|
||||
def _inotify_read(fd: int, timeout_secs: float | None = None) -> list[str]:
|
||||
"""Read pending inotify events. Returns list of filenames that were
|
||||
created/moved-to. Blocks until at least one event or timeout."""
|
||||
poll = select.poll()
|
||||
poll.register(fd, select.POLLIN)
|
||||
events = []
|
||||
buf = os.read(fd, 4096)
|
||||
pos = 0
|
||||
while pos < len(buf):
|
||||
# struct: wd(i32), mask(u32), cookie(u32), name_len(u32)
|
||||
wd, mask, cookie, name_len = struct.unpack_from("iIII", buf, pos)
|
||||
pos += 16
|
||||
name = buf[pos : pos + name_len].rstrip(b"\x00").decode("utf-8", "replace")
|
||||
pos += name_len
|
||||
if mask & (IN_CREATE | IN_MOVED_TO):
|
||||
events.append(name)
|
||||
return events
|
||||
|
||||
|
||||
def wait_for_completion(operation_id: str, *, timeout_secs: float | None = None, poll_callback=None) -> dict | None:
|
||||
"""Block until a completion file exists for this operation.
|
||||
|
||||
Uses inotify for zero-latency, zero-CPU wake. Falls back to
|
||||
polling_callback (if provided) when inotify is unavailable.
|
||||
|
||||
The completion file is a WAKE SIGNAL. The caller must still verify
|
||||
canonical authority after reading it.
|
||||
|
||||
Returns the event dict from the completion file, or None on timeout.
|
||||
"""
|
||||
target = completion_path(operation_id)
|
||||
|
||||
# 1. check-before-watch (event may have arrived before we started)
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
_resumes_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
fd = _inotify_init_and_add_watch(str(_resumes_dir()))
|
||||
except OSError:
|
||||
# inotify unavailable: fall back to polling callback
|
||||
if poll_callback:
|
||||
return poll_callback(operation_id, timeout_secs)
|
||||
return None
|
||||
|
||||
try:
|
||||
# 2. re-check after watch registration (closes the race between
|
||||
# the initial check and the watch being active)
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# 3. block in kernel on inotify — zero CPU, exact latency
|
||||
import select as _select
|
||||
|
||||
poll = _select.poll()
|
||||
poll.register(fd, _select.POLLIN)
|
||||
deadline = (time.monotonic() + timeout_secs) if timeout_secs else None
|
||||
|
||||
while True:
|
||||
remaining = None
|
||||
if deadline is not None:
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
if remaining <= 0.1:
|
||||
return None # timeout
|
||||
events = poll.poll(remaining * 1000 if remaining else None)
|
||||
if not events:
|
||||
continue # poll timeout, re-check
|
||||
names = _inotify_read(fd)
|
||||
if target.name in names:
|
||||
# 4. file appeared — read it
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# partial/corrupt: keep watching
|
||||
finally:
|
||||
os.close(fd)
|
||||
return None
|
||||
|
||||
|
||||
def clear_completion(operation_id: str) -> None:
|
||||
"""Remove a consumed completion file (idempotent cleanup)."""
|
||||
p = completion_path(operation_id)
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
Executable
+799
@@ -0,0 +1,799 @@
|
||||
#!/usr/bin/env python3
|
||||
"""daemon.py — Gitea webhook receiver + reconciliation + resume driver (v0.2).
|
||||
|
||||
W/R-v0.2 invariants implemented here:
|
||||
A. SINGLE-FLIGHT PER SESSION — per-session lease (waitstore.acquire_lease,
|
||||
/proc-validated, atomic) + in-process inflight set. A second READY wait
|
||||
for the same session stays queued until the lease releases.
|
||||
B. BUSY SESSION PROTECTION — before spawning, /proc is scanned for a live
|
||||
`mimo run -s <sid>`-shaped process not owned by our lease. Found → skip
|
||||
launch (wait stays READY). No invented MiMo APIs; the lease is the
|
||||
conservative floor against W/R-created concurrency.
|
||||
C. ATOMIC SESSION LEASE — persisted {session_id, wait_id, pid, pgid,
|
||||
pid_starttime, acquired_at, state}; O_EXCL create, steal only on /proc
|
||||
evidence of a dead owner.
|
||||
D. PROCESS TREE OWNERSHIP — workers spawn with start_new_session=True (own
|
||||
process group). Watch timeout ⇒ SIGTERM group → bounded grace → SIGKILL
|
||||
group → verify zero surviving group members → release lease → truthful
|
||||
READY_TO_RETRY (never terminal-on-timeout).
|
||||
E. TIMEOUT IS NOT SUCCESS — rc==0 maps to verdict terminal
|
||||
(COMPLETED/FAILED); rc!=0 or unverifiable ⇒ RESUME_UNVERIFIED
|
||||
(not schedulable, operator-requeueable); timeout ⇒ READY_TO_RETRY.
|
||||
F. STALE RESUMING RECOVERY — on startup, every RESUMING record is
|
||||
inspected: live lease owner ⇒ adopted and monitored; otherwise ⇒
|
||||
RESUME_UNVERIFIED (+ stale lease released). Never auto-relaunched.
|
||||
G. MULTIPLE WAITS, SAME SESSION — schedulable() orders oldest-first; one
|
||||
continuation finishes before the next starts.
|
||||
H. PIDFILE CORRECTNESS — singleton enforced by flock on daemon.pid; the
|
||||
lock dies with the process, so stale pidfiles (incl. SIGKILL) cannot
|
||||
admit a second daemon.
|
||||
|
||||
The resume command is configurable (WR_RESUME_CMD, JSON list with
|
||||
{session_id}/{msg} placeholders) so the test harness can use a stub; the
|
||||
default is the real mechanism: `mimo run -s <sid> -m <model> <msg>`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import fcntl, json, os, signal, subprocess, sys, threading, time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import waitstore
|
||||
import verify
|
||||
import completion_file # REAL_ASYNC_BRIDGE R3: durable completion-file delivery
|
||||
|
||||
RESUME_MODEL = "Verboo Code/glm-5.3-flash"
|
||||
RECONCILE_SECS = int(os.environ.get("WR_RECONCILE_SECS", "60"))
|
||||
RECONCILE_EVERY_TICKS = max(1, int(os.environ.get("WR_RECONCILE_TICKS", "3")))
|
||||
WAKE_MIN_AGE_SECS = float(os.environ.get("WR_WAKE_MIN_AGE_SECS", "5"))
|
||||
WAKE_RETRIES = int(os.environ.get("WR_WAKE_RETRIES", "3"))
|
||||
WAKE_RETRY_SECS = float(os.environ.get("WR_WAKE_RETRY_SECS", "4"))
|
||||
DRIVE_LOOP_SECS = int(os.environ.get("WR_DRIVE_SECS", "20"))
|
||||
# REAL_ASYNC_BRIDGE §4: a RESUMING wait older than this grace without
|
||||
# progress is an orphaned resume flow (thread died between lease acquire
|
||||
# and release). Must exceed the longest legitimate in-flight watch
|
||||
# (WATCH_SECS); default 3x. Env-overridable for deterministic tests.
|
||||
WATCH_SECS = int(os.environ.get("WR_WATCH_SECS", "600"))
|
||||
# REAL_ASYNC_BRIDGE §4: a RESUMING wait older than this grace without
|
||||
# progress is an orphaned resume flow (thread died between lease acquire
|
||||
# and release). Must exceed the longest legitimate in-flight watch
|
||||
# (WATCH_SECS); default 3x. Env-overridable for deterministic tests.
|
||||
ORPHAN_GRACE_SECS = int(os.environ.get("WR_ORPHAN_GRACE_SECS", str(3 * WATCH_SECS)))
|
||||
TERM_GRACE_SECS = int(os.environ.get("WR_TERM_GRACE_SECS", "10"))
|
||||
MAX_ATTEMPTS = int(os.environ.get("WR_MAX_ATTEMPTS", "3"))
|
||||
_WR_ROOT = Path(os.environ.get("WR_ROOT", "") or Path(__file__).resolve().parent)
|
||||
PIDFILE = _WR_ROOT / "daemon.pid"
|
||||
LOG = _WR_ROOT / "daemon.log"
|
||||
|
||||
DEFAULT_RESUME_CMD = ["mimo", "run", "-s", "{session_id}", "-m", RESUME_MODEL, "{msg}"]
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
with open(LOG, "a") as f:
|
||||
f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
|
||||
|
||||
|
||||
def resume_cmd(session_id: str, msg: str, wait_id: str = "", override: list | None = None) -> list[str]:
|
||||
"""T15 §15D: per-wait override wins; placeholders {session_id}/{msg}/
|
||||
{wait_id}. A synthetic controller session (Scaffolding stage driver)
|
||||
is not a MiMo session — the global default cannot resume it, which is
|
||||
exactly why fixed-sleep polling crept back into T12-T14."""
|
||||
if override:
|
||||
return [
|
||||
c.replace("{session_id}", session_id).replace("{msg}", msg).replace("{wait_id}", wait_id) for c in override
|
||||
]
|
||||
tmpl = os.environ.get("WR_RESUME_CMD")
|
||||
cmd = json.loads(tmpl) if tmpl else DEFAULT_RESUME_CMD
|
||||
return [c.replace("{session_id}", session_id).replace("{msg}", msg).replace("{wait_id}", wait_id) for c in cmd]
|
||||
|
||||
|
||||
# ── process-tree evidence & ownership ─────────────────────────────────────
|
||||
|
||||
|
||||
def pgid_members(pgid: int) -> list[int]:
|
||||
out = []
|
||||
for d in Path("/proc").iterdir():
|
||||
if not d.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
stat = (d / "stat").read_text()
|
||||
fields = stat.rsplit(")", 1)[1].split()
|
||||
# zombies: killed but not yet reaped. Counting them makes the
|
||||
# survivor check report the direct child we just KILLed before
|
||||
# communicate() reaps it (observed live 2026-09-12).
|
||||
if fields[0] == "Z":
|
||||
continue
|
||||
if int(fields[2]) == pgid:
|
||||
out.append(int(d.name))
|
||||
except (OSError, IndexError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def terminate_tree(pgid: int) -> list[int]:
|
||||
"""TERM → bounded grace → KILL. Returns surviving members (must be [])."""
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
deadline = time.time() + TERM_GRACE_SECS
|
||||
while time.time() < deadline:
|
||||
if not pgid_members(pgid):
|
||||
return []
|
||||
time.sleep(0.2)
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
for _ in range(50): # up to 10s for KILL to land
|
||||
if not pgid_members(pgid):
|
||||
return []
|
||||
time.sleep(0.2)
|
||||
return pgid_members(pgid)
|
||||
|
||||
|
||||
def stray_worker_pids(session_id: str, exclude_pgids: set[int] = ()) -> list[int]:
|
||||
"""Live `mimo run -s <sid>`-shaped processes not owned by our workers."""
|
||||
out = []
|
||||
for d in Path("/proc").iterdir():
|
||||
if not d.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
parts = [p.decode(errors="replace") for p in (d / "cmdline").read_bytes().split(b"\0") if p]
|
||||
except OSError:
|
||||
continue
|
||||
if not parts:
|
||||
continue
|
||||
joined = " ".join(parts)
|
||||
if session_id not in joined or "-s" not in parts:
|
||||
continue
|
||||
if "mimo" not in joined.lower() and "@mimo-ai" not in joined.lower():
|
||||
continue
|
||||
try:
|
||||
if os.getpgid(int(d.name)) in exclude_pgids:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
out.append(int(d.name))
|
||||
return out
|
||||
|
||||
|
||||
# ── verification → transition (unchanged authority model) ─────────────────
|
||||
|
||||
|
||||
def _authoritative_verify(rec: dict) -> tuple[str, dict]:
|
||||
"""Authoritative verification with a deterministic test seam.
|
||||
|
||||
Production (WR_VERIFY_STUB unset): real Gitea API via verify.py — the
|
||||
ONLY source of success/failure truth. Test (WR_VERIFY_STUB=path to
|
||||
JSON {"<run_ref-or-sha>": verdict, "default": verdict}): stubbed
|
||||
verdicts so invariant tests never touch the network. The stub is a
|
||||
test fixture, not an alternate authority — no production path sets it.
|
||||
"""
|
||||
stub_path = os.environ.get("WR_VERIFY_STUB", "")
|
||||
if stub_path:
|
||||
try:
|
||||
spec = json.loads(Path(stub_path).read_text())
|
||||
except Exception:
|
||||
return "pending", {"stub_error": "unreadable stub"}
|
||||
key = str(rec.get("run_ref", "") or rec.get("sha", ""))
|
||||
verdict = spec.get(key, spec.get("default", "pending"))
|
||||
return verdict, {"stub": True, "key": key}
|
||||
import verify
|
||||
|
||||
if rec.get("wait_kind") == "RELEASE_DEPLOY":
|
||||
return verify.verify_release_deploy(rec.get("sha", ""), rec.get("repo_root", ""), rec.get("deployed_link", ""))
|
||||
return verify.verify_run(rec.get("run_ref", ""), rec.get("sha", ""), rec.get("expected", "success"))
|
||||
|
||||
|
||||
def try_transition(rec: dict, event: dict | None = None) -> dict:
|
||||
if rec["status"] != "WAITING_EXTERNAL":
|
||||
return rec
|
||||
if rec.get("wait_kind") == "PROCESS_EVENT":
|
||||
# T15 §15D: the matching event IS the authority. The notifier
|
||||
# wrapper observed the real process exit; exit_code decides the
|
||||
# verdict. No Gitea lookup applies. A duplicate event never
|
||||
# reaches here (match applies only to WAITING_EXTERNAL waits).
|
||||
if not event:
|
||||
return rec # no event → nothing authoritative happened
|
||||
try:
|
||||
exit_code = int(event.get("exit_code"))
|
||||
except (TypeError, ValueError):
|
||||
exit_code = None
|
||||
if exit_code is None:
|
||||
return rec # malformed event: stay WAITING (15E hostile case)
|
||||
verdict = "success" if exit_code == 0 else "failure"
|
||||
evidence = {"process_event": True, "exit_code": exit_code, "operation_id": event.get("run_ref", "")}
|
||||
log(f"verify {rec['wait_id']}: {verdict} {json.dumps(evidence)[:120]}")
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["verdict"] = verdict
|
||||
if verdict == "failure":
|
||||
rec["failure_evidence"] = evidence
|
||||
rec["process_event_evidence"] = evidence
|
||||
waitstore.save(rec)
|
||||
return rec
|
||||
verdict, evidence = _authoritative_verify(rec)
|
||||
log(f"verify {rec['wait_id']}: {verdict} {json.dumps(evidence)[:120]}")
|
||||
if verdict == "success":
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["verdict"] = "success"
|
||||
elif verdict == "failure":
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["verdict"] = "failure"
|
||||
rec["failure_evidence"] = verify.failed_job_evidence(rec.get("run_ref", ""))
|
||||
else:
|
||||
return rec # pending/unknown → stay WAITING
|
||||
waitstore.save(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def continuation_msg(rec: dict) -> str:
|
||||
c = rec["continuation"]
|
||||
if rec.get("verdict") == "success":
|
||||
return (
|
||||
f"External wait satisfied (wait {rec['wait_id']}).\n"
|
||||
f"Original objective: {c.get('objective', '')}\n"
|
||||
f"Waited for: {c.get('waited_for', '')}\n"
|
||||
f"Authoritative result: workflow success verified via Gitea API.\n"
|
||||
f"SHA/run: {rec.get('sha', '')}/{rec.get('run_ref', '')}\n"
|
||||
f"Verify: {c.get('verify_when_woken', '')}\n"
|
||||
f"Continue from: {c.get('on_success', '')}"
|
||||
)
|
||||
ev = json.dumps(rec.get("failure_evidence", {}))[:400]
|
||||
return (
|
||||
f"External wait finished with FAILURE (wait {rec['wait_id']}).\n"
|
||||
f"Original objective: {c.get('objective', '')}\n"
|
||||
f"Authoritative result: workflow FAILURE verified via Gitea API.\n"
|
||||
f"Failed job evidence: {ev}\n"
|
||||
f"SHA/run: {rec.get('sha', '')}/{rec.get('run_ref', '')}\n"
|
||||
f"Do NOT treat this as success. Continue from: {c.get('on_failure', '')}"
|
||||
)
|
||||
|
||||
|
||||
# ── resume driver (single-flight, tree-owned) ─────────────────────────────
|
||||
|
||||
|
||||
def resume(rec: dict) -> None:
|
||||
"""Run one continuation to completion. Runs on a dedicated worker thread.
|
||||
Preconditions: caller verified no inflight worker for this session."""
|
||||
sid, wid = rec["session_id"], rec["wait_id"]
|
||||
# REAL_ASYNC_BRIDGE §2 invariant: a successful authority-verified
|
||||
# operation completion must NEVER be overwritten or downgraded by a
|
||||
# legacy session/FIFO delivery failure. Terminal operation state
|
||||
# (COMPLETED/FAILED — stamped by the waiter's own authority
|
||||
# verification) is owned by the OPERATION domain; this function owns
|
||||
# only SESSION-DELIVERY. Re-read from disk: the in-memory copy may be
|
||||
# stale relative to the waiter's terminal stamp.
|
||||
fresh = waitstore.load(wid)
|
||||
if fresh and fresh.get("status") in waitstore.TERMINAL:
|
||||
log(
|
||||
f"resume {wid}: skipped — operation already terminal "
|
||||
f"({fresh['status']}); delivery worker never overrides "
|
||||
f"operation truth"
|
||||
)
|
||||
return
|
||||
rec = fresh or rec
|
||||
msg = continuation_msg(rec)
|
||||
|
||||
# B: busy-session guard before touching the lease
|
||||
strays = stray_worker_pids(sid)
|
||||
if strays:
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["resume_note"] = f"queued: target session busy (stray pids {strays})"
|
||||
waitstore.save(rec)
|
||||
log(f"queued {wid}: session busy {strays}")
|
||||
return
|
||||
|
||||
# pre-register the lease owner as THIS daemon so acquisition is atomic
|
||||
lease = waitstore.acquire_lease(sid, wid, os.getpid(), os.getpid())
|
||||
if lease is None:
|
||||
live = waitstore.read_lease(sid)
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["resume_note"] = f"queued: lease held by wait {live and live.get('wait_id')}"
|
||||
waitstore.save(rec)
|
||||
log(f"queued {wid}: lease held")
|
||||
return
|
||||
|
||||
rec["status"] = "RESUMING"
|
||||
rec["resume_attempts"] = rec.get("resume_attempts", 0) + 1
|
||||
waitstore.save(rec)
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
resume_cmd(sid, msg, wid, rec.get("resume_cmd")),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
waitstore.release_lease(sid, wid)
|
||||
rec["status"] = "READY_TO_RETRY" # spawn failed pre-delivery → retryable
|
||||
rec["resume_note"] = f"spawn failed: {e}"
|
||||
waitstore.save(rec)
|
||||
log(f"spawn {wid} failed pre-delivery: {e}")
|
||||
return
|
||||
|
||||
pgid = proc.pid # start_new_session ⇒ pgid == child pid
|
||||
waitstore.update_lease(sid, wait_id=wid, pid=proc.pid, pgid=pgid, pid_starttime=waitstore._pid_starttime(proc.pid))
|
||||
log(f"launched {wid}: pid={proc.pid} pgid={pgid} attempt={rec['resume_attempts']}")
|
||||
|
||||
timed_out = False
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=WATCH_SECS)
|
||||
rc = proc.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
survivors = terminate_tree(pgid)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=30)
|
||||
except Exception:
|
||||
stdout, stderr = "", ""
|
||||
rc = proc.returncode
|
||||
if survivors:
|
||||
log(f"ERROR {wid}: group {pgid} survivors after KILL: {survivors}")
|
||||
|
||||
waitstore.release_lease(sid, wid)
|
||||
|
||||
if timed_out:
|
||||
# D+E: timeout is NOT success. Tree terminated; retry truthfully.
|
||||
if rec["resume_attempts"] >= MAX_ATTEMPTS:
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
rec["resume_note"] = (
|
||||
f"watch timeout x{rec['resume_attempts']}; process group {pgid} terminated; attempts exhausted"
|
||||
)
|
||||
else:
|
||||
rec["status"] = "READY_TO_RETRY"
|
||||
rec["resume_note"] = f"watch timeout; process group {pgid} terminated (TERM→KILL, verified); will retry"
|
||||
log(f"timeout {wid}: tree {pgid} terminated, rc={rc}")
|
||||
elif rc == 0:
|
||||
# COMPLETION TRUTH (constraint 4): rc==0 means the headless worker
|
||||
# EXITED CLEANLY — the strongest terminal evidence the supported CLI
|
||||
# provides. Timeout/cancel can never reach this branch. The evidence
|
||||
# chain (rc, output tail, timing) is recorded on the wait record.
|
||||
terminal = "COMPLETED" if rec.get("verdict") == "success" else "FAILED"
|
||||
rec["status"] = terminal
|
||||
rec["resume_evidence"] = {
|
||||
"kind": "worker_exited_rc0",
|
||||
"rc": 0,
|
||||
"output_tail": (stdout or "")[-400:],
|
||||
"finished_at": waitstore.now(),
|
||||
}
|
||||
log(f"resumed {wid}: rc=0 → {terminal}")
|
||||
else:
|
||||
# Nonzero rc: delivery/completion unproven. Honest, not schedulable.
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
rec["resume_evidence"] = {
|
||||
"kind": "worker_exited_nonzero",
|
||||
"rc": rc,
|
||||
"output_tail": ((stdout or "") + (stderr or ""))[-400:],
|
||||
"finished_at": waitstore.now(),
|
||||
}
|
||||
rec["resume_note"] = f"resume rc={rc}; delivery unverified"
|
||||
log(f"resumed {wid}: rc={rc} → RESUME_UNVERIFIED")
|
||||
waitstore.save(rec)
|
||||
|
||||
|
||||
# ── startup recovery (invariant F) ────────────────────────────────────────
|
||||
|
||||
|
||||
def startup_recovery() -> None:
|
||||
for rec in waitstore.all_waits():
|
||||
if rec.get("status") != "RESUMING":
|
||||
continue
|
||||
sid, wid = rec["session_id"], rec["wait_id"]
|
||||
lease = waitstore.read_lease(sid)
|
||||
if lease and lease.get("wait_id") == wid and waitstore._owner_alive(lease):
|
||||
# Adopt the live worker and monitor it to a bounded conclusion.
|
||||
pgid = lease["pgid"]
|
||||
log(f"recover {wid}: adopted live worker pgid={pgid}")
|
||||
deadline = time.time() + WATCH_SECS
|
||||
while time.time() < deadline and pgid_members(pgid):
|
||||
time.sleep(2)
|
||||
survivors = terminate_tree(pgid) if pgid_members(pgid) else []
|
||||
waitstore.release_lease(sid, wid)
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
rec["resume_note"] = "adopted post-restart; outcome unverifiable" + (
|
||||
f"; survivors killed: {survivors}" if survivors else ""
|
||||
)
|
||||
waitstore.save(rec)
|
||||
log(f"recover {wid}: adopted worker ended → RESUME_UNVERIFIED")
|
||||
else:
|
||||
if lease:
|
||||
waitstore.release_lease(sid, wid) # stale
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
rec["resume_note"] = "recovered at daemon startup: no live owner; delivery unverifiable"
|
||||
waitstore.save(rec)
|
||||
log(f"recover {wid}: no live owner → RESUME_UNVERIFIED")
|
||||
|
||||
|
||||
# ── reconciler + drive loop (invariant G: deterministic queue) ────────────
|
||||
|
||||
_inflight: dict[str, threading.Thread] = {}
|
||||
_inflight_lock = threading.Lock()
|
||||
|
||||
|
||||
def drive_ready() -> None:
|
||||
for rec in waitstore.schedulable():
|
||||
sid = rec["session_id"]
|
||||
# CONTENTION POLICY (constraint 2): MiMo exposes NO supported
|
||||
# busy/idle session signal (verified 2026-09-12: `mimo session list
|
||||
# --format json` has no status field; no `session status` command;
|
||||
# no status column in the session schema; no documented ACP/serve
|
||||
# endpoint). Therefore automatic injection into interactive sessions
|
||||
# is NOT YET SAFE and stays disabled. Only waits explicitly declared
|
||||
# target_kind="headless" (disposable/owned sessions) auto-dispatch.
|
||||
if rec.get("target_kind") != "headless":
|
||||
if not rec.get("hold_reason"):
|
||||
rec["hold_reason"] = (
|
||||
"INTERACTIVE_AUTO_RESUME = NOT YET SAFE: "
|
||||
"no supported MiMo busy/idle signal; "
|
||||
"queued until operator releases"
|
||||
)
|
||||
waitstore.save(rec)
|
||||
continue
|
||||
if rec.get("ext", {}).get("resume_mode") == "self":
|
||||
# REAL_ASYNC_BRIDGE §3: self-resuming wait (completion-file /
|
||||
# inotify waiter owns its continuation). The daemon has NO
|
||||
# delivery role here — dispatching a delivery worker would
|
||||
# stamp a phantom rc!=0 over the waiter's own truthful
|
||||
# terminal state (the canonical-state contradiction).
|
||||
# NB: keyed on the explicit self marker, NOT on resume_cmd
|
||||
# absence — records without inline resume_cmd may still be
|
||||
# dispatchable via the WR_RESUME_CMD env template.
|
||||
continue
|
||||
with _inflight_lock:
|
||||
t = _inflight.get(sid)
|
||||
if t is not None and t.is_alive():
|
||||
continue # single-flight: session already being driven
|
||||
# B: never launch into a busy session
|
||||
strays = stray_worker_pids(sid)
|
||||
if strays:
|
||||
log(f"drive: {rec['wait_id']} held, session busy {strays}")
|
||||
continue
|
||||
th = threading.Thread(target=resume, args=(rec,), daemon=True, name=f"resume-{rec['wait_id']}")
|
||||
_inflight[sid] = th
|
||||
# start INSIDE the lock: a just-created thread is not yet
|
||||
# is_alive() until start() runs — starting outside the lock let
|
||||
# the same drive cycle dispatch a second worker for the session
|
||||
# (single-flight race, caught by the T2 invariant test).
|
||||
th.start()
|
||||
log(f"drive: dispatched {rec['wait_id']} (session {sid})")
|
||||
|
||||
|
||||
def reconcile_once() -> None:
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] != "WAITING_EXTERNAL":
|
||||
continue
|
||||
created = rec.get("created_at", "")
|
||||
try:
|
||||
import calendar
|
||||
|
||||
age = time.time() - calendar.timegm(time.strptime(created, "%Y-%m-%dT%H:%M:%SZ"))
|
||||
except ValueError:
|
||||
continue
|
||||
if age > RECONCILE_SECS:
|
||||
try_transition(rec)
|
||||
|
||||
|
||||
_tick = 0
|
||||
|
||||
|
||||
def orphan_sweep() -> int:
|
||||
"""REAL_ASYNC_BRIDGE §4: permanent mid-run orphan-lease recovery.
|
||||
|
||||
FAILURE DETECTION ONLY — never involved in normal completion
|
||||
scheduling. A wait stuck in RESUMING past ORPHAN_GRACE_SECS means the
|
||||
resume flow died between lease acquire and release (worker thread
|
||||
lost, exception mid-watch). Reconcile exactly as startup_recovery
|
||||
would have: release the lease, stamp RESUME_UNVERIFIED truthfully
|
||||
(delivery unverifiable), never auto-relaunch.
|
||||
"""
|
||||
import calendar as _cal
|
||||
|
||||
healed = 0
|
||||
for rec in waitstore.all_waits():
|
||||
if rec.get("status") != "RESUMING":
|
||||
continue
|
||||
try:
|
||||
updated = _cal.timegm(time.strptime(rec.get("last_updated", ""), "%Y-%m-%dT%H:%M:%SZ"))
|
||||
except ValueError:
|
||||
continue
|
||||
if time.time() - updated < ORPHAN_GRACE_SECS:
|
||||
continue # legitimate in-flight watch (up to WATCH_SECS)
|
||||
wid, sid = rec["wait_id"], rec["session_id"]
|
||||
lease = waitstore.read_lease(sid)
|
||||
owner_dead = lease is not None and not waitstore._owner_alive(lease)
|
||||
daemon_owned = lease is not None and lease.get("pid") == os.getpid()
|
||||
if lease is None or owner_dead or daemon_owned:
|
||||
if lease is not None:
|
||||
waitstore.release_lease(sid, wid)
|
||||
if rec.get("resume_attempts", 0) >= MAX_ATTEMPTS:
|
||||
rec["status"] = "RESUME_UNVERIFIED"
|
||||
else:
|
||||
rec["status"] = "READY_TO_RETRY"
|
||||
rec["resume_note"] = (
|
||||
"orphaned mid-run: resume flow died between lease acquire "
|
||||
"and delivery ("
|
||||
+ ("lease owner dead" if owner_dead else "resume thread lost" if daemon_owned else "no lease")
|
||||
+ "); orphan sweep recovered"
|
||||
)
|
||||
waitstore.save(rec)
|
||||
log(f"orphan-sweep {wid}: {rec['status']} — {rec['resume_note']}")
|
||||
healed += 1
|
||||
return healed
|
||||
|
||||
|
||||
def reconciler() -> None:
|
||||
global _tick
|
||||
while True:
|
||||
# v0.3: the local-runner wake is the fast path; periodic
|
||||
# authoritative verification demotes to a slower fallback
|
||||
# (missed-event + daemon-restart recovery), not API polling.
|
||||
_tick += 1
|
||||
if _tick % RECONCILE_EVERY_TICKS == 0:
|
||||
try:
|
||||
reconcile_once()
|
||||
except Exception as e:
|
||||
log(f"reconcile error: {e}")
|
||||
try:
|
||||
orphan_sweep() # §4 failure detection — bounded, periodic
|
||||
except Exception as e:
|
||||
log(f"orphan-sweep error: {e}")
|
||||
try:
|
||||
drive_ready()
|
||||
except Exception as e:
|
||||
log(f"drive error: {e}")
|
||||
with _inflight_lock:
|
||||
for sid, t in list(_inflight.items()):
|
||||
if not t.is_alive():
|
||||
del _inflight[sid]
|
||||
time.sleep(DRIVE_LOOP_SECS)
|
||||
|
||||
|
||||
# ── webhook receiver (wake signal only) ───────────────────────────────────
|
||||
|
||||
|
||||
def wake_sweep(event: dict, eid: str) -> dict:
|
||||
"""Sweep WAITING_EXTERNAL waits through authoritative verification.
|
||||
|
||||
Runs on a background thread (the hook's HTTP response returns
|
||||
instantly, minimising the runner's offline window). If any verified
|
||||
wait got 'pending' — the normal case for a job that finished
|
||||
microseconds ago: the post-task hook fires after results are reported
|
||||
but before the runner's final task acknowledgement flips server-side
|
||||
status — retry on a BOUNDED event-triggered schedule (default 4s x3),
|
||||
never a polling loop. The final ack lands milliseconds after the hook
|
||||
exits, so the first retry almost always sees the terminal state.
|
||||
"""
|
||||
arrival = time.time()
|
||||
log(f"local wake {eid}: {json.dumps(event)[:140]}")
|
||||
import calendar
|
||||
|
||||
def sweep() -> tuple[int, int, int]:
|
||||
verified = advanced = pending = 0
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] != "WAITING_EXTERNAL":
|
||||
continue
|
||||
try:
|
||||
age = time.time() - calendar.timegm(time.strptime(rec.get("created_at", ""), "%Y-%m-%dT%H:%M:%SZ"))
|
||||
except ValueError:
|
||||
continue
|
||||
if age < WAKE_MIN_AGE_SECS:
|
||||
continue
|
||||
verified += 1
|
||||
before = rec["status"]
|
||||
try_transition(rec)
|
||||
if rec["status"] != before and rec["status"] == "READY_TO_RESUME":
|
||||
advanced += 1
|
||||
elif rec["status"] == "WAITING_EXTERNAL":
|
||||
pending += 1
|
||||
return verified, advanced, pending
|
||||
|
||||
verified, advanced, pending = sweep()
|
||||
log(
|
||||
f"local wake {eid}: verified={verified} advanced={advanced} "
|
||||
f"pending={pending} sweep_ms={int((time.time() - arrival) * 1000)}"
|
||||
)
|
||||
for attempt in range(1, WAKE_RETRIES + 1):
|
||||
# Retry while the sweep didn't conclusively advance anything:
|
||||
# pending verdicts (ack-window) AND fresh-wait skips (age gate).
|
||||
# Bounded, event-triggered — not a polling loop.
|
||||
if not pending and verified:
|
||||
break
|
||||
time.sleep(WAKE_RETRY_SECS)
|
||||
verified, advanced, pending = sweep()
|
||||
log(
|
||||
f"local wake {eid}: retry{attempt} verified={verified} "
|
||||
f"advanced={advanced} pending={pending} "
|
||||
f"total_ms={int((time.time() - arrival) * 1000)}"
|
||||
)
|
||||
return {"event_id": eid}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path == "/process-event":
|
||||
# T15 §15D: process-exit events from builder/controller
|
||||
# notifiers. Minimal dedicated endpoint — the Gitea webhook
|
||||
# path stays untouched. Payload: {operation_id, exit_code,
|
||||
# completed_at} + OPTIONAL provider/event_type/sha (T16
|
||||
# §16C/§16E: the CI adapter registers as provider="gitea",
|
||||
# event_type="ci_completed"; defaults preserve the T15 builder
|
||||
# identity so existing notifiers are unaffected). The event is
|
||||
# a WAKE, never truth — resumed controllers re-read canonical
|
||||
# authority before any verdict.
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
event = {
|
||||
"provider": str(payload.get("provider", "builder")),
|
||||
"event_type": str(payload.get("event_type", "builder_exit")),
|
||||
"run_ref": str(payload.get("operation_id", "")) or None,
|
||||
"sha": payload.get("sha"),
|
||||
"exit_code": payload.get("exit_code"),
|
||||
"completed_at": payload.get("completed_at") or "",
|
||||
}
|
||||
event = {k: v for k, v in event.items() if v is not None}
|
||||
eid = waitstore.record_event(event)
|
||||
log(f"process-event {eid}: {json.dumps(event)[:140]}")
|
||||
# REAL_ASYNC_BRIDGE (R3): durable completion-file delivery.
|
||||
# Every event reaching the bridge is written as
|
||||
# resumes/<operation_id>.done so inotify waiters wake on the
|
||||
# kernel event (R4) with no FIFO/lease machinery, and the
|
||||
# event survives daemon AND waiter restarts (R9). Atomic
|
||||
# rename; duplicate events rewrite identical content (R10).
|
||||
_op_id = str(payload.get("operation_id", ""))
|
||||
if _op_id:
|
||||
try:
|
||||
# Stable identity only: duplicate deliveries of the
|
||||
# same logical event must produce byte-identical
|
||||
# completion files (R10 idempotence by construction).
|
||||
# completed_at is volatile and lives in the durable
|
||||
# event log; the file is a WAKE SIGNAL, never truth.
|
||||
_stable = {
|
||||
k: event.get(k)
|
||||
for k in ("provider", "event_type", "run_ref", "sha", "exit_code")
|
||||
if event.get(k) is not None
|
||||
}
|
||||
completion_file.write_completion(_op_id, _stable)
|
||||
except Exception as e: # noqa: BLE001 — delivery best-effort; wait-record transition + watchdog recovery still cover the lost file
|
||||
log(f"process-event {eid}: completion-file write failed: {e}")
|
||||
matched = 0
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] == "WAITING_EXTERNAL" and waitstore.match(rec, event):
|
||||
matched += 1
|
||||
try_transition(rec, event)
|
||||
# matched READY controller waits are dispatched IMMEDIATELY
|
||||
# (event-driven, no floor). Guard: only never-resumed waits —
|
||||
# a duplicate/late event must not double-dispatch (15E);
|
||||
# single-flight lease + this guard together make resume
|
||||
# idempotent.
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] == "READY_TO_RESUME" and rec.get("resume_cmd") and rec.get("resume_attempts", 0) == 0:
|
||||
threading.Thread(target=resume, args=(rec,), daemon=True, name=f"resume-{rec['wait_id']}").start()
|
||||
matched += 1
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"event_id": eid, "matched": matched}).encode())
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
if self.path == "/wake":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
event = {
|
||||
"provider": "gitea-local-runner",
|
||||
"task_id": str(payload.get("task_id", "")) or None,
|
||||
"run_id": str(payload.get("run_id", "")) or None,
|
||||
"repository": str(payload.get("repository", "")) or None,
|
||||
"job_result": str(payload.get("job_result", "")) or None,
|
||||
}
|
||||
event = {k: v for k, v in event.items() if v}
|
||||
eid = waitstore.record_event(event)
|
||||
# Respond instantly; sweep on a worker thread. The hook is
|
||||
# fire-and-forget (curl -m 2) — a broken pipe on write is
|
||||
# harmless and must not propagate.
|
||||
try:
|
||||
threading.Thread(
|
||||
target=wake_sweep, args=(event, eid), daemon=True, name=f"wake-{payload.get('task_id', '?')}"
|
||||
).start()
|
||||
except Exception as e:
|
||||
log(f"wake spawn error: {e}")
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"queued": True, "event_id": eid}).encode())
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
if self.path != "/webhook":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
action = payload.get("action") or payload.get("event_type") or ""
|
||||
wf = payload.get("workflow_run") or {}
|
||||
event = {
|
||||
"provider": "gitea",
|
||||
"event_type": f"workflow.{action}" if wf else action,
|
||||
"sha": (wf.get("head_sha") or payload.get("sha") or "")[:12] or None,
|
||||
"run_ref": str(wf.get("id") or payload.get("run_number") or "") or None,
|
||||
}
|
||||
event = {k: v for k, v in event.items() if v}
|
||||
eid = waitstore.record_event(event)
|
||||
log(f"webhook event {eid}: {json.dumps(event)[:140]}")
|
||||
matched = 0
|
||||
for rec in waitstore.pending():
|
||||
if rec["status"] == "WAITING_EXTERNAL" and waitstore.match(rec, event):
|
||||
matched += 1
|
||||
try_transition(rec)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"event_id": eid, "matched": matched}).encode())
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
return
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8791
|
||||
|
||||
# H (constraint 1): flock is the SINGLETON AUTHORITY. daemon.pid's
|
||||
# content is diagnostic metadata only — it never gates startup, never
|
||||
# implies liveness, and is overwritten after the exclusive lock is held.
|
||||
# Stale pid content (incl. SIGKILL debris) is thus harmless by design.
|
||||
PIDFILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_fd = os.open(PIDFILE, os.O_CREAT | os.O_RDWR, 0o644)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
log("refusing start: another daemon holds the pidfile flock")
|
||||
print("daemon already running (pidfile flock held)", file=sys.stderr)
|
||||
return 1
|
||||
os.ftruncate(lock_fd, 0)
|
||||
os.write(lock_fd, f"{os.getpid()}\n".encode()) # diagnostic, lock held for life
|
||||
|
||||
startup_recovery()
|
||||
t = threading.Thread(target=reconciler, daemon=True)
|
||||
t.start()
|
||||
log(f"daemon starting on :{port}")
|
||||
srv = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""verify.py — authoritative Gitea verification (webhook ≠ success).
|
||||
|
||||
Queries the Gitea API for the TRUE state of the run/job/commit the wait
|
||||
references. Returns (verdict, evidence). verdict ∈ {success, failure,
|
||||
pending, unknown}. Only this module may move a wait out of
|
||||
WAITING_EXTERNAL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HOST = "https://gitea.thingscouldgetdicey.online"
|
||||
REPO = "conductor/rune"
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
# Canonical ladder without importing Rune: env → token file → git store
|
||||
import os
|
||||
|
||||
tok = os.environ.get("GITEA_TOKEN", "").strip()
|
||||
if tok:
|
||||
return tok
|
||||
tf = os.path.expanduser("~/.config/opencode/secrets/gitea-token")
|
||||
if os.path.isfile(tf):
|
||||
return open(tf).read().strip()
|
||||
r = subprocess.run(
|
||||
["git", "credential", "fill"],
|
||||
input="protocol=https\nhost=gitea.thingscouldgetdicey.online\nusername=conductor\n\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
for line in r.stdout.splitlines():
|
||||
if line.startswith("password="):
|
||||
return line[9:]
|
||||
raise RuntimeError("no gitea credential available")
|
||||
|
||||
|
||||
def _api(path: str) -> tuple[int, object]:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(f"{HOST}/api/v1/repos/{REPO}{path}", headers={"Authorization": f"token {_token()}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return r.status, json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, {"error": e.read().decode()[:200]}
|
||||
|
||||
|
||||
def _jobs_list(run_ref: str):
|
||||
"""GET jobs for a run, unwrapping the {'jobs': [...]} envelope."""
|
||||
s, data = _api(f"/actions/runs/{run_ref}/jobs")
|
||||
if s != 200:
|
||||
return s, None
|
||||
if isinstance(data, dict):
|
||||
data = data.get("jobs", [])
|
||||
return s, data if isinstance(data, list) else None
|
||||
|
||||
|
||||
def _resolve_run_id(run_ref: str, sha: str) -> str:
|
||||
"""Task-list ids are NOT run ids. If run_ref lookup 404s, resolve the
|
||||
real run id by head SHA via /actions/runs (T15: run 6907 vs task 15743)."""
|
||||
if run_ref:
|
||||
s, jobs = _jobs_list(run_ref)
|
||||
if s == 200 and jobs:
|
||||
return run_ref
|
||||
if sha:
|
||||
s, runs = _api(f"/actions/runs?limit=30")
|
||||
items = runs.get("workflow_runs", []) if isinstance(runs, dict) else (runs if isinstance(runs, list) else [])
|
||||
# One SHA fans out to multiple runs (lint + deploy-only). Prefer a
|
||||
# substantive (non-skipped) run; a skipped-only run proves nothing.
|
||||
fallback = ""
|
||||
for r in items:
|
||||
if str(r.get("head_sha", "")).startswith(sha):
|
||||
rid = str(r.get("id"))
|
||||
if r.get("conclusion") != "skipped":
|
||||
return rid
|
||||
fallback = fallback or rid
|
||||
return fallback
|
||||
return ""
|
||||
|
||||
|
||||
def verify_run(run_ref: str, sha: str, expected: str) -> tuple[str, dict]:
|
||||
"""run_ref = actions run id (or task id — resolved via SHA lookup).
|
||||
|
||||
Defect fixed 2026-09-12 (surfaced by W/R-v0.3 live proof, run 7110):
|
||||
the jobs query here used raw _api + isinstance(jobs, list), but this
|
||||
Gitea wraps jobs in the {"jobs": [...]} envelope — the check always
|
||||
failed, so EVERY EXACT_WORKFLOW verdict silently fell through to the
|
||||
aggregate commit-status fallback, where unrelated runs on the same
|
||||
SHA (multi-run fan-out: ci.yml + deploy-only) poison the verdict.
|
||||
Use _jobs_list (unwraps the envelope), same as _resolve_run_id."""
|
||||
run_ref = _resolve_run_id(run_ref, sha)
|
||||
if run_ref:
|
||||
s, jobs = _jobs_list(run_ref)
|
||||
if s == 200 and isinstance(jobs, list) and jobs:
|
||||
conclusions = {j.get("name"): j.get("conclusion") for j in jobs}
|
||||
statuses = {j.get("status") for j in jobs}
|
||||
if statuses <= {"completed"}:
|
||||
verdict = "success" if set(conclusions.values()) <= {"success", "skipped"} else "failure"
|
||||
return verdict, {"run_ref": run_ref, "jobs": conclusions}
|
||||
return "pending", {"run_ref": run_ref, "statuses": sorted(statuses)}
|
||||
if sha:
|
||||
s, st = _api(f"/commits/{sha}/status")
|
||||
if s == 200 and isinstance(st, dict):
|
||||
state = st.get("state", "unknown") # success|error|failure|pending|warning
|
||||
rid = _resolve_run_id("", sha) # attach run id so failure evidence works
|
||||
if state == "success":
|
||||
return "success", {"sha": sha, "state": state, "run_ref": rid}
|
||||
if state in ("error", "failure"):
|
||||
return "failure", {"sha": sha, "state": state, "run_ref": rid}
|
||||
return "pending", {"sha": sha, "state": state}
|
||||
return "unknown", {"run_ref": run_ref, "sha": sha}
|
||||
|
||||
|
||||
def failed_job_evidence(run_ref: str, sha: str = "") -> dict:
|
||||
"""For failure continuations: exact failing job + conclusion."""
|
||||
run_ref = _resolve_run_id(run_ref, sha)
|
||||
if not run_ref:
|
||||
return {}
|
||||
s, jobs = _jobs_list(run_ref)
|
||||
if s != 200 or not isinstance(jobs, list):
|
||||
return {}
|
||||
bad = [j for j in jobs if j.get("conclusion") not in ("success", "skipped", None)]
|
||||
return {
|
||||
"failed_jobs": [
|
||||
{"name": j.get("name"), "conclusion": j.get("conclusion"), "url": j.get("url", "")} for j in bad
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ── RELEASE_DEPLOY verification (W/R v0.1, overnight handoff Part 2) ──────
|
||||
# Aggregate commit status is NOT a valid completion authority for Rune's
|
||||
# release/deploy flow: the release successor SHA, not the source SHA, is what
|
||||
# converges (observed live: wr-1789179026 stranded on pending while 0.66.15
|
||||
# was deployed). The authority chain is:
|
||||
# source SHA → source runs terminal → release successor commit (descendant)
|
||||
# → successor runs success → deployed/current identity converges.
|
||||
|
||||
|
||||
def _git(args: list[str], repo_root: str) -> str:
|
||||
import subprocess
|
||||
|
||||
r = subprocess.run(["git"] + args, capture_output=True, text=True, cwd=repo_root or None, timeout=15)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args[:2])}: {r.stderr.strip()[:120]}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def _runs_for_sha(sha: str) -> list[dict]:
|
||||
s, runs = _api("/actions/runs?limit=50")
|
||||
items = runs.get("workflow_runs", []) if isinstance(runs, dict) else []
|
||||
return [r for r in items if str(r.get("head_sha", "")).startswith(sha)]
|
||||
|
||||
|
||||
def _sha_verdict_from_runs(runs: list[dict]) -> tuple[str, dict]:
|
||||
"""success | failure | pending from a SHA's workflow runs.
|
||||
Terminal verdict ONLY when every run is completed — mixed states
|
||||
(some success, some still in flight) must stay PENDING (observed live:
|
||||
a SHA with 1 completed-success + 1 queued + 1 in-progress was judged
|
||||
success mid-flight; wr-1789182522 2026-09-12)."""
|
||||
if not runs:
|
||||
return "pending", {"note": "no runs found"}
|
||||
non_terminal = [r for r in runs if r.get("status") != "completed"]
|
||||
if non_terminal:
|
||||
return "pending", {"in_flight": len(non_terminal)}
|
||||
conclusions = {r.get("name"): r.get("conclusion") for r in runs}
|
||||
bad = {n: c for n, c in conclusions.items() if c == "failure"}
|
||||
if bad:
|
||||
return "failure", {"failed": bad}
|
||||
return "success", {"runs": conclusions}
|
||||
|
||||
|
||||
def verify_release_deploy(source_sha: str, repo_root: str = "", deployed_link: str = "") -> tuple[str, dict]:
|
||||
"""RELEASE_DEPLOY authority chain. Returns (verdict, evidence)."""
|
||||
source_sha = (source_sha or "")[:12]
|
||||
ev: dict = {"source_sha": source_sha, "kind": "RELEASE_DEPLOY"}
|
||||
|
||||
# 1. Source SHA's own runs must be terminal and healthy.
|
||||
src_runs = _runs_for_sha(source_sha)
|
||||
src_verdict, src_ev = _sha_verdict_from_runs(src_runs)
|
||||
ev["source"] = src_ev
|
||||
if src_verdict != "success":
|
||||
return src_verdict, ev
|
||||
|
||||
# 2. Release successor: first chore(release) commit descending from source.
|
||||
if not repo_root:
|
||||
return "pending", {**ev, "note": "no repo_root — cannot check lineage"}
|
||||
try:
|
||||
out = _git(
|
||||
[
|
||||
"log",
|
||||
"--format=%H %s",
|
||||
"--reverse",
|
||||
f"{source_sha}..master",
|
||||
"--grep=^chore\\(release\\)",
|
||||
"--extended-regexp",
|
||||
],
|
||||
repo_root,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
return "pending", {**ev, "note": str(e)}
|
||||
successors = [l for l in out.splitlines() if l.strip()]
|
||||
if not successors:
|
||||
return "pending", {**ev, "note": "no release successor yet"}
|
||||
succ_line = successors[0]
|
||||
succ_sha_full, _, succ_subject = succ_line.partition(" ")
|
||||
succ_sha = succ_sha_full[:12]
|
||||
ev["successor"] = {"sha": succ_sha, "subject": succ_subject[:80]}
|
||||
|
||||
# Ancestry is guaranteed by the range, but verify explicitly (deterministic
|
||||
# lineage over title guessing):
|
||||
try:
|
||||
_git(["merge-base", "--is-ancestor", source_sha, succ_sha_full], repo_root)
|
||||
except RuntimeError:
|
||||
return "failure", {**ev, "note": "successor is not a descendant of source"}
|
||||
|
||||
# 3. Successor runs must be terminal and healthy.
|
||||
succ_runs = _runs_for_sha(succ_sha)
|
||||
succ_verdict, succ_ev = _sha_verdict_from_runs(succ_runs)
|
||||
ev["successor"]["runs"] = succ_ev
|
||||
if succ_verdict != "success":
|
||||
return succ_verdict, ev
|
||||
|
||||
# 4. Deployed identity convergence. A mismatch is FAILURE only when the
|
||||
# successor's runs are ALL completed — if any is still running/queued the
|
||||
# deploy may simply not have propagated yet → PENDING (observed live:
|
||||
# reconciler checked mid-deploy, verdict failure, deployment converged
|
||||
# ~2 min later; wr-1789182522 2026-09-12).
|
||||
if deployed_link:
|
||||
link = Path(deployed_link)
|
||||
if link.is_symlink() or link.is_dir():
|
||||
deployed = str(link.resolve())
|
||||
deployed_id = Path(deployed).name
|
||||
ev["deployed"] = deployed_id
|
||||
if not deployed_id.startswith(succ_sha[:12]):
|
||||
still = [r for r in _runs_for_sha(succ_sha) if r.get("status") != "completed"]
|
||||
if still:
|
||||
return "pending", {**ev, "note": "deploy propagating (runs in flight)"}
|
||||
return "failure", {**ev, "note": f"deployed {deployed_id} != successor {succ_sha} (all runs terminal)"}
|
||||
else:
|
||||
return "failure", {**ev, "note": f"deployed link missing: {deployed_link}"}
|
||||
|
||||
return "success", ev
|
||||
Executable
+337
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""waitstore.py — wait/resume state machine v0.2 (developer tooling, NOT Rune).
|
||||
|
||||
States:
|
||||
RUNNABLE → WAITING_EXTERNAL → READY_TO_RESUME → RESUMING → COMPLETED | FAILED
|
||||
↑ │
|
||||
│ ├─(watch timeout, tree killed)
|
||||
│ └─(rc!=0 / unverifiable)
|
||||
READY_TO_RETRY ←─────┘
|
||||
RESUME_UNVERIFIED : delivery after crash/timeout cannot be proven.
|
||||
TERMINAL-ish — never auto-scheduled; explicit
|
||||
`requeue()` required to make it READY_TO_RESUME.
|
||||
TIMED_OUT : external event never arrived within the wait budget.
|
||||
|
||||
Waits persist as waits/<wait_id>.json (atomic tmp+rename).
|
||||
Leases persist as leases/<session_id>.lease.json (atomic tmp+rename,
|
||||
validated against /proc so a dead or PID-reused owner is stealable).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import fcntl, json, os, tempfile, time, uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(os.environ.get("WR_ROOT", "") or Path(__file__).resolve().parent)
|
||||
WAITS = ROOT / "waits"
|
||||
EVENTS = ROOT / "events"
|
||||
LEASES = ROOT / "leases"
|
||||
|
||||
SCHEDULABLE = {"READY_TO_RESUME", "READY_TO_RETRY"}
|
||||
VALID_STATES = {
|
||||
"RUNNABLE",
|
||||
"WAITING_EXTERNAL",
|
||||
"READY_TO_RESUME",
|
||||
"RESUMING",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"READY_TO_RETRY",
|
||||
"RESUME_UNVERIFIED",
|
||||
"TIMED_OUT",
|
||||
}
|
||||
TERMINAL = {"COMPLETED", "FAILED", "RESUME_UNVERIFIED", "TIMED_OUT"}
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".tmp-")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, indent=1, sort_keys=True)
|
||||
os.replace(tmp, path)
|
||||
# T17 17C: the bridge root is SHARED across users (deploy waiter
|
||||
# runs as the runner user; daemon as nick). mkstemp creates 0600 —
|
||||
# daemon re-saves locked the runner user out of records it must
|
||||
# read (waitstore.load in await_resume_event; find_matching_event
|
||||
# replay scan silently skipped them). 0644: owner writes, all read.
|
||||
os.chmod(path, 0o644)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def new_wait(
|
||||
*,
|
||||
session_id: str,
|
||||
provider: str,
|
||||
event_type: str,
|
||||
repo: str,
|
||||
sha: str = "",
|
||||
run_ref: str = "",
|
||||
expected: str = "success",
|
||||
wait_kind: str = "EXACT_WORKFLOW",
|
||||
target_kind: str = "interactive",
|
||||
repo_root: str = "",
|
||||
deployed_link: str = "",
|
||||
objective: str = "",
|
||||
waited_for: str = "",
|
||||
verify_when_woken: str = "",
|
||||
on_success: str = "",
|
||||
on_failure: str = "",
|
||||
resume_cmd: list | None = None,
|
||||
) -> dict:
|
||||
wid = f"wr-{int(time.time())}-{uuid.uuid4().hex[:6]}"
|
||||
rec = {
|
||||
"wait_id": wid,
|
||||
"session_id": session_id,
|
||||
"created_at": now(),
|
||||
"provider": provider,
|
||||
"event_type": event_type,
|
||||
"repo": repo,
|
||||
"sha": sha,
|
||||
"run_ref": run_ref,
|
||||
"expected": expected,
|
||||
# T15 §15D: PROCESS_EVENT — a wait whose authoritative verdict is
|
||||
# the matching process-exit event itself (exit_code carried on the
|
||||
# event), e.g. a builder/controller process completing. The event
|
||||
# IS the authority here (the notifier wrapper observed the real
|
||||
# process exit); no Gitea lookup applies.
|
||||
"wait_kind": wait_kind
|
||||
if wait_kind in ("EXACT_WORKFLOW", "RELEASE_DEPLOY", "PROCESS_EVENT")
|
||||
else "EXACT_WORKFLOW",
|
||||
# CONTENTION POLICY: "interactive" (default, conservative) never
|
||||
# auto-dispatches — MiMo exposes NO supported busy/idle session
|
||||
# signal, so injecting into a possibly-busy interactive session is
|
||||
# NOT YET SAFE. Only explicitly declared headless/disposable
|
||||
# targets ("headless") are auto-resumed.
|
||||
"target_kind": target_kind if target_kind in ("headless", "interactive") else "interactive",
|
||||
"repo_root": repo_root,
|
||||
"deployed_link": deployed_link,
|
||||
# T15 §15D: per-wait resume command override (placeholder template,
|
||||
# same substitution as WR_RESUME_CMD: {session_id}/{msg}/{wait_id}).
|
||||
# A synthetic controller session (e.g. a Scaffolding stage driver)
|
||||
# is NOT a MiMo session — the global mimo-shaped default cannot
|
||||
# resume it, which is exactly why fixed-sleep polling crept back
|
||||
# in T12-T14. Per-wait override makes ANY registered continuation
|
||||
# target resumable through the same bridge.
|
||||
"resume_cmd": list(resume_cmd) if resume_cmd else None,
|
||||
"status": "WAITING_EXTERNAL",
|
||||
"last_verified": "",
|
||||
"resume_attempts": 0,
|
||||
"continuation": {
|
||||
"objective": objective,
|
||||
"waited_for": waited_for,
|
||||
"verify_when_woken": verify_when_woken,
|
||||
"on_success": on_success,
|
||||
"on_failure": on_failure,
|
||||
},
|
||||
}
|
||||
_atomic_write(WAITS / f"{wid}.json", rec)
|
||||
return rec
|
||||
|
||||
|
||||
def load(wid: str) -> dict | None:
|
||||
p = WAITS / f"{wid}.json"
|
||||
return json.loads(p.read_text()) if p.is_file() else None
|
||||
|
||||
|
||||
def save(rec: dict) -> None:
|
||||
assert rec.get("status") in VALID_STATES, rec.get("status")
|
||||
rec["last_updated"] = now()
|
||||
_atomic_write(WAITS / f"{rec['wait_id']}.json", rec)
|
||||
|
||||
|
||||
def all_waits() -> list[dict]:
|
||||
out = []
|
||||
for p in WAITS.glob("*.json"):
|
||||
try:
|
||||
out.append(json.loads(p.read_text()))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def pending() -> list[dict]:
|
||||
return [r for r in all_waits() if r.get("status") in {"WAITING_EXTERNAL"} | SCHEDULABLE]
|
||||
|
||||
|
||||
def schedulable() -> list[dict]:
|
||||
"""READY waits, oldest first — deterministic queue order."""
|
||||
rs = [r for r in all_waits() if r.get("status") in SCHEDULABLE]
|
||||
rs.sort(key=lambda r: (r.get("created_at", ""), r.get("wait_id", "")))
|
||||
return rs
|
||||
|
||||
|
||||
def requeue(wid: str, note: str) -> dict | None:
|
||||
"""Operator-explicit requeue of a RESUME_UNVERIFIED/RESUME_FAILED wait."""
|
||||
rec = load(wid)
|
||||
if not rec:
|
||||
return None
|
||||
rec["status"] = "READY_TO_RESUME"
|
||||
rec["resume_note"] = note
|
||||
save(rec)
|
||||
return rec
|
||||
|
||||
|
||||
# ── events (wake signals, idempotent) ─────────────────────────────────────
|
||||
|
||||
|
||||
def record_event(event: dict) -> str:
|
||||
import hashlib
|
||||
|
||||
body = json.dumps(event, sort_keys=True)
|
||||
eid = hashlib.sha256(body.encode()).hexdigest()[:16]
|
||||
f = EVENTS / f"{eid}.json"
|
||||
if not f.is_file():
|
||||
_atomic_write(f, {"event_id": eid, "received_at": now(), "event": event})
|
||||
return eid
|
||||
|
||||
|
||||
def match(rec: dict, event: dict) -> bool:
|
||||
if rec.get("provider") != event.get("provider"):
|
||||
return False
|
||||
if rec.get("event_type") and event.get("event_type") and rec["event_type"] != event["event_type"]:
|
||||
return False
|
||||
if rec.get("sha") and event.get("sha") and rec["sha"] != event["sha"]:
|
||||
return False
|
||||
if rec.get("run_ref") and event.get("run_ref") and rec["run_ref"] != event["run_ref"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── leases (per-session single-flight, /proc-validated) ───────────────────
|
||||
#
|
||||
# Invariant 3 (PID reuse): liveness is NEVER "PID exists". The lease records
|
||||
# pid + pgid + process starttime (/proc/<pid>/stat field 22); all three must
|
||||
# match or the owner is a different process wearing a recycled PID.
|
||||
#
|
||||
# Invariant 1 (flock as authority): the lease file's flock serializes
|
||||
# read-modify-write across processes and dies with its holder, so a crashed
|
||||
# daemon can never wedge the lock. The lease JSON + /proc decide who OWNS the
|
||||
# session; the flock only prevents racing acquirers from double-stealing.
|
||||
|
||||
_lease_fds: dict[str, int] = {} # session_id → open fd holding the flock
|
||||
|
||||
|
||||
def _pid_starttime(pid: int) -> str | None:
|
||||
"""Field 22 of /proc/<pid>/stat — distinguishes PID reuse."""
|
||||
try:
|
||||
stat = Path(f"/proc/{pid}/stat").read_text()
|
||||
return stat.rsplit(")", 1)[1].split()[19]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _owner_alive(lease: dict) -> bool:
|
||||
pid = lease.get("pid")
|
||||
if not pid or not Path(f"/proc/{pid}").is_dir():
|
||||
return False
|
||||
if _pid_starttime(pid) != lease.get("pid_starttime"):
|
||||
return False # PID reused by an unrelated process
|
||||
try:
|
||||
if os.getpgid(pid) != lease.get("pgid"):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def lease_path(session_id: str) -> Path:
|
||||
# T16 §16C finding (observed live in daemon.log): session ids like
|
||||
# "controller-<op>-Verboo Code/glm-5.3-flash" contain "/" — the raw
|
||||
# embed made lease creation raise FileNotFoundError, so resume
|
||||
# delivery for those waits could never succeed. Sanitize to a flat
|
||||
# filesystem-safe name; the hash suffix prevents collisions between
|
||||
# ids that sanitize identically.
|
||||
import hashlib as _hashlib
|
||||
import re as _re
|
||||
|
||||
safe = _re.sub(r"[^A-Za-z0-9._-]+", "_", session_id)[:80]
|
||||
digest = _hashlib.sha256(session_id.encode()).hexdigest()[:10]
|
||||
return LEASES / f"{safe}-{digest}.lease.json"
|
||||
|
||||
|
||||
def read_lease(session_id: str) -> dict | None:
|
||||
p = lease_path(session_id)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _locked_lease_fd(session_id: str) -> int | None:
|
||||
"""Open the lease file and take its flock (non-blocking). The fd stays
|
||||
open (lock held) until release_lease closes it."""
|
||||
if session_id in _lease_fds:
|
||||
return _lease_fds[session_id] # already hold it
|
||||
LEASES.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(lease_path(session_id), os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
return None # another process holds the lock — mid-acquire
|
||||
_lease_fds[session_id] = fd
|
||||
return fd
|
||||
|
||||
|
||||
def _write_lease_locked(session_id: str, lease: dict) -> None:
|
||||
fd = _lease_fds[session_id]
|
||||
os.ftruncate(fd, 0)
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
os.write(fd, json.dumps(lease, indent=1, sort_keys=True).encode())
|
||||
os.fsync(fd)
|
||||
|
||||
|
||||
def acquire_lease(session_id: str, wait_id: str, pid: int, pgid: int) -> dict | None:
|
||||
"""Atomically create or steal the session lease. Returns the lease dict,
|
||||
or None if a live owner holds it (or another process is mid-steal).
|
||||
Staleness is decided by /proc identity evidence, never by age alone."""
|
||||
fd = _locked_lease_fd(session_id)
|
||||
if fd is None:
|
||||
return None
|
||||
existing = read_lease(session_id)
|
||||
if existing and _owner_alive(existing):
|
||||
_lease_fds.pop(session_id, None)
|
||||
os.close(fd)
|
||||
return None # live worker owns this session
|
||||
lease = {
|
||||
"session_id": session_id,
|
||||
"wait_id": wait_id,
|
||||
"pid": pid,
|
||||
"pgid": pgid,
|
||||
"pid_starttime": _pid_starttime(pid),
|
||||
"acquired_at": now(),
|
||||
"state": "RESUMING",
|
||||
}
|
||||
_write_lease_locked(session_id, lease)
|
||||
return lease
|
||||
|
||||
|
||||
def update_lease(session_id: str, **fields) -> None:
|
||||
lease = read_lease(session_id) or {}
|
||||
lease.update(fields)
|
||||
_write_lease_locked(session_id, lease)
|
||||
|
||||
|
||||
def release_lease(session_id: str, wait_id: str) -> bool:
|
||||
"""Release only if the current lease belongs to this wait (no stealing
|
||||
a successor's lease during a slow shutdown path). Closes the flock fd."""
|
||||
lease = read_lease(session_id)
|
||||
if not (lease and lease.get("wait_id") == wait_id):
|
||||
return False
|
||||
fd = _lease_fds.pop(session_id, None)
|
||||
if fd is not None:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
os.close(fd)
|
||||
lease_path(session_id).unlink(missing_ok=True)
|
||||
return True
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Hooks } from "@mimo-ai/plugin"
|
||||
|
||||
// event-wait-guard — RETIRED (T21.5 §6/§8, 2026-09-17).
|
||||
//
|
||||
// HISTORY: this hook was the T21 §21B-3 runtime interception layer that
|
||||
// blocked model-generated sleep→poll continuation (T20 regression), and
|
||||
// later carried the T21.1 §21.1C cwd guard. Both were DOWNSTREAM patches:
|
||||
// they corrected the model's output instead of fixing the generator.
|
||||
//
|
||||
// ROOT-CAUSE FIX (T21.5 §2-§5): the generator was ci_event_wait's
|
||||
// wait loop returning nonterminal control to the model — caused by a
|
||||
// single hard-coded master-deploy authority that was blind to branch/PR
|
||||
// operations. T21.5 fixed this upstream:
|
||||
// - OPERATION_SPECS: per-operation-type completion authority
|
||||
// (branch_ci/pull_request_checks -> own Actions runs;
|
||||
// master_ci/deploy/release -> deploy qualification);
|
||||
// - SINGLE LOGICAL AWAIT: all nonterminal conditions re-arm inside
|
||||
// ci_event_wait.run(); the model sees only terminal results;
|
||||
// - structured PendingOperation envelopes (op_type, authority,
|
||||
// internal_rearm_count) propagate to the controller.
|
||||
//
|
||||
// With the generator fixed, runtime interception is retired per the
|
||||
// tranche rule FIXED UPSTREAM BEHAVIOUR > DOWNSTREAM GUARD CORRECTION.
|
||||
// Regression prevention lives in the test suite:
|
||||
// tests/test_single_logical_await.py (no model re-arm contract)
|
||||
// tests/test_ci_event_wait.py (watchdog=recovery, event=wake)
|
||||
// tests/test_wait_guard_corpus.py (canonical wait-pattern corpus)
|
||||
// NOT as a permanent runtime block. The hook file stays installed as a
|
||||
// documented no-op so canonical-source/install parity (test_hook_drift,
|
||||
// test_hook_parity) continues to prove the installed runtime matches
|
||||
// this repository's canonical state.
|
||||
|
||||
export const EventWaitGuard: Hooks = {
|
||||
"tool.execute.before": async (_input, _output) => {
|
||||
// RETIRED — no interception. See history above.
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
export const CwdGuard: Hooks = {
|
||||
"tool.execute.before": async (_input, _output) => {
|
||||
// RETIRED (T21.5 §8) — the harness/construction execution boundary
|
||||
// now resolves project context upstream (execution-context binding).
|
||||
// No runtime refusal. See tests/test_auto_execution_context.py.
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
export const CombinedGuards: Hooks = {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
// Kept as the default export shape; both sub-guards are retired.
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
export default CombinedGuards
|
||||
@@ -0,0 +1,54 @@
|
||||
import { tool } from "@mimo-ai/plugin"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
// await_operation — T21.3 §21.3C: FIRST-CLASS async wait tool.
|
||||
// Uses the plugin tool() API with args (not parameters).
|
||||
|
||||
export const AwaitOperation = tool({
|
||||
description: [
|
||||
"Wait for an asynchronous external operation (CI, deploy, release) to complete.",
|
||||
"Registers on the event bridge, yields until the completion event arrives, then returns the verified result.",
|
||||
"USE THIS instead of `sleep N; curl status` — it is the canonical event-driven wait.",
|
||||
].join(" "),
|
||||
args: {
|
||||
sha: tool.schema.string().describe(
|
||||
"The exact commit SHA the operation is running against"),
|
||||
deadline_secs: tool.schema.number().optional().describe(
|
||||
"Maximum wait in seconds (default 900)"),
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const repoRoot = "/mnt/workdrive/issue161-harness"
|
||||
const deadline = args.deadline_secs || 900
|
||||
const cmd = [
|
||||
"python3", `${repoRoot}/scripts/ci_event_wait.py`,
|
||||
"--sha", args.sha,
|
||||
"--deadline", String(deadline),
|
||||
].join(" ")
|
||||
try {
|
||||
const result = execSync(cmd, {
|
||||
cwd: repoRoot,
|
||||
timeout: (deadline + 60) * 1000,
|
||||
encoding: "utf-8" as const,
|
||||
env: {
|
||||
...process.env,
|
||||
DEPLOY_SHA: args.sha,
|
||||
RUNE_WR_ROOT: "/mnt/workdrive/wr-bridge",
|
||||
RUNE_WR_CODE: "/home/nick/.local/share/mimo-wait-resume",
|
||||
},
|
||||
})
|
||||
const lines = result.trim().split("\n")
|
||||
const jsonLine = lines.find((l: string) => l.startsWith("{"))
|
||||
if (jsonLine) return jsonLine
|
||||
return "await_operation: no JSON output"
|
||||
} catch (e: any) {
|
||||
const stdout = e.stdout || ""
|
||||
const lines = stdout.trim().split("\n")
|
||||
const jsonLine = lines.find((l: string) => l.startsWith("{"))
|
||||
if (jsonLine) return jsonLine
|
||||
return JSON.stringify({
|
||||
ok: false,
|
||||
error: `await_operation failed: ${(e.stderr || e.message || "?").slice(0, 200)}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
+269
-222
@@ -1,225 +1,272 @@
|
||||
{
|
||||
"_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"
|
||||
]
|
||||
}
|
||||
"_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-16T18:23:38Z",
|
||||
"umbrellas": {
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
"modules": [
|
||||
"adaptive_router",
|
||||
"builder_bridge",
|
||||
"builder_bridge_cli",
|
||||
"capability_broker",
|
||||
"connection_pool",
|
||||
"evidence_probe",
|
||||
"forge",
|
||||
"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",
|
||||
"programme_gates"
|
||||
],
|
||||
"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",
|
||||
"behaviour_ledger",
|
||||
"build_basis",
|
||||
"capability_fit",
|
||||
"capability_ledger",
|
||||
"cognitive_budgeter",
|
||||
"construction_activation",
|
||||
"construction_commands",
|
||||
"construction_decision",
|
||||
"construction_depth",
|
||||
"construction_intent",
|
||||
"construction_interface",
|
||||
"construction_mode",
|
||||
"construction_pattern",
|
||||
"construction_patterns_builtin",
|
||||
"construction_projection",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"drift_resume",
|
||||
"event_wait",
|
||||
"failure_recovery",
|
||||
"graph_projection",
|
||||
"impact_preview",
|
||||
"iteration",
|
||||
"layout_contract",
|
||||
"programme_continuation",
|
||||
"project_registry",
|
||||
"release_notes",
|
||||
"salience_gate",
|
||||
"scaffold",
|
||||
"spine",
|
||||
"stage_guards",
|
||||
"structural_migration",
|
||||
"structural_model",
|
||||
"structural_store",
|
||||
"testing_mode",
|
||||
"toolchain",
|
||||
"tui_backlog",
|
||||
"wait_guard",
|
||||
"wait_guard_corpus",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,15 +104,15 @@
|
||||
},
|
||||
{
|
||||
"path": "src/rune/attention_capsule.py",
|
||||
"role": "AttentionCapsule — the typed ledger of admitted blocks the model receives (to_messages re-derives the runtime payload)",
|
||||
"role": "AttentionCapsule \u2014 the typed ledger of admitted blocks the model receives (to_messages re-derives the runtime payload)",
|
||||
"disposition": "retain-authority",
|
||||
"evidence": "P1-02 (#25): the capsule IS the product the model receives — the broker renders admitted blocks into it, the safety override mutates its user block in place (re-measured), runtime.run consumes capsule.to_messages(), and the trace v4 capsule_messages/capsule_content_hashes prove the logged capsule EXACTLY equals the sent payload. Legacy create_capsule/to_prompt (string-trim fake budgeting) deprecated with no live callers."
|
||||
"evidence": "P1-02 (#25): the capsule IS the product the model receives \u2014 the broker renders admitted blocks into it, the safety override mutates its user block in place (re-measured), runtime.run consumes capsule.to_messages(), and the trace v4 capsule_messages/capsule_content_hashes prove the logged capsule EXACTLY equals the sent payload. Legacy create_capsule/to_prompt (string-trim fake budgeting) deprecated with no live callers."
|
||||
},
|
||||
{
|
||||
"path": "src/rune/inference_trace.py",
|
||||
"role": "Durable per-run inference trace (tokens served/estimated, output, latency, per-block visibility)",
|
||||
"disposition": "migrate-adapter",
|
||||
"evidence": "P0-07 (#23): persists what ContextBroker (#19) assembled and what the transport served — builds FROM the broker trace, never re-measures; store state/inference_traces.jsonl."
|
||||
"evidence": "P0-07 (#23): persists what ContextBroker (#19) assembled and what the transport served \u2014 builds FROM the broker trace, never re-measures; store state/inference_traces.jsonl."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -160,13 +160,13 @@
|
||||
"path": "src/rune/capability_broker.py",
|
||||
"role": "Authoritative capability selector (select_for_job + verified floor + find_capability escape hatch)",
|
||||
"disposition": "retain-authority",
|
||||
"evidence": "P1-01 (#24): select_for_job is the single selection authority — its ordered decision feeds LocalToolRuntime(tools=) at both construction sites and the _contributor_tools prompt block; degrade-not-crash falls back to full TOOLS with a tier-1 record_failure."
|
||||
"evidence": "P1-01 (#24): select_for_job is the single selection authority \u2014 its ordered decision feeds LocalToolRuntime(tools=) at both construction sites and the _contributor_tools prompt block; degrade-not-crash falls back to full TOOLS with a tier-1 record_failure."
|
||||
},
|
||||
{
|
||||
"path": "memory/index/capability_cards.json",
|
||||
"role": "Capability-card index (rebuilt on empty/stale/corrupt; native cards synthesized from TOOLS metadata)",
|
||||
"disposition": "migrate-adapter",
|
||||
"evidence": "P1-01 (#24): load_cards validates and rebuilds — the packaged empty array no longer passes as an index; cards carry risk/preconditions/side_effects copied from tool_executor.TOOLS."
|
||||
"evidence": "P1-01 (#24): load_cards validates and rebuilds \u2014 the packaged empty array no longer passes as an index; cards carry risk/preconditions/side_effects copied from tool_executor.TOOLS."
|
||||
},
|
||||
{
|
||||
"path": "src/rune/local_tool_runtime.py",
|
||||
@@ -486,12 +486,6 @@
|
||||
"disposition": "retain-authority",
|
||||
"evidence": "Defines the owner-approved canary-first Gitea sequence."
|
||||
},
|
||||
{
|
||||
"path": "src/rune/plugins/build_mode/__init__.py",
|
||||
"role": "Checkpoint/ledger state-machine primitive used by current governed builds",
|
||||
"disposition": "migrate-adapter",
|
||||
"evidence": "Persists checkpoints but encodes an older phase order."
|
||||
},
|
||||
{
|
||||
"path": "src/rune/build_mode.py",
|
||||
"role": "Separate BuildManager and worktree engine",
|
||||
@@ -504,6 +498,9 @@
|
||||
"disposition": "archive-candidate",
|
||||
"evidence": "Reports fixed success strings for phases without executing their claimed effects."
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"T17 17U: plugin BuildStateMachine deleted (zero importers; second parallel orchestrator)"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -540,4 +537,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Rune Semantic Release Policy (T20 §20B)
|
||||
|
||||
Pre-1.0 convention. Scaffolding "V1" does NOT mean Rune 1.0.
|
||||
|
||||
## Bump kinds
|
||||
|
||||
**PATCH** (0.66.80 → 0.66.81):
|
||||
- bug fix, correction, implementation/internal maintenance
|
||||
- no meaningful new user/system capability
|
||||
|
||||
**MINOR** (0.66.80 → 0.67.0):
|
||||
- meaningful new Rune capability
|
||||
- new subsystem functionality
|
||||
- materially expanded system behaviour
|
||||
- significant new supported workflow
|
||||
|
||||
**MAJOR**: reserved for Rune 1.0+ compatibility/public-contract semantics.
|
||||
|
||||
## How the machinery expresses this (no manual version edits)
|
||||
|
||||
The existing machinery already maps commit shape → bump kind:
|
||||
- `semver_bump.next_version` (PR titles): feat→minor, else patch
|
||||
- `version_convergence.highest_warranted_bump` (push ranges):
|
||||
feat/breaking→minor, fix/perf→patch, else None
|
||||
|
||||
**Tranche-level rule (20B)**: a feature tranche lands its feature
|
||||
commits (`feat(...)`), and the convergence machinery bumps MINOR at
|
||||
the tranche's release boundary. Patch-only follow-ups (fix/perf)
|
||||
warrant only PATCH — the machinery cannot accidentally re-minor
|
||||
because `highest_warranted_bump` takes the max over the *post-
|
||||
boundary* range, and a fix-only range yields "patch".
|
||||
|
||||
## Why T20 → 0.67.0
|
||||
|
||||
T20 is a feature-level tranche (Rune-wide retrofit: structural
|
||||
taxonomy, capability/behaviour ledgers, rune graph, /testing backlog,
|
||||
Spine modernization, semantic release). The release is classified
|
||||
MINOR under this policy, earned through the governed convergence
|
||||
machinery — never a manual version edit.
|
||||
|
||||
## T20 0.67.0 release record
|
||||
T20 classified MINOR under this policy: Rune-wide retrofit (structural
|
||||
taxonomy, capability/behaviour ledgers, rune graph, /testing backlog,
|
||||
Spine modernization, semantic release) = new subsystem functionality +
|
||||
materially expanded system behaviour. Earned through the governed
|
||||
convergence machinery (feat-bearing range → minor), never a manual edit.
|
||||
|
||||
## Root cause record (T20, 2026-09-16)
|
||||
semver_bump re-derived the bump kind from the SYNTHESIZED title text —
|
||||
"minor: converged release" matched nothing → patch. Feature tranches
|
||||
T18/T19/T20 downgraded to 0.66.77-83. Fixed: MINOR_PREFIX_RE carries the
|
||||
convergence kind through; first push after the fix with a feat-bearing
|
||||
range lands 0.67.0 through the normal machinery.
|
||||
@@ -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.
|
||||
@@ -68,3 +68,208 @@ for a 131072-window model (observed live in every PL1 Task A/B run).
|
||||
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,79 @@
|
||||
# SCAFFOLDING IMPLEMENTATION AUDIT + V1 GAP MATRIX (T18 §18B)
|
||||
|
||||
_Every claim classified against evidence. Scale: LIVE_PROVEN (production
|
||||
evidence) / TEST_PROVEN (suite green, no production exercise) /
|
||||
IMPLEMENTED_UNPROVEN / PARTIAL / PLANNED / ABSENT / LEGACY / DUPLICATE._
|
||||
|
||||
## Kernel claims
|
||||
|
||||
| Claim | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Contract lifecycle + legal transitions | LIVE_PROVEN | 28 contracts driven through real flow; refusal envelopes observed |
|
||||
| classify_mode (one classifier) | LIVE_PROVEN | gates TUI /build (17G), operator hook, tests 62+ |
|
||||
| Conversational intent layer | TEST_PROVEN | 22+ held-outs incl. unseen paraphrases; 17R live exercise (one real conversational proof) |
|
||||
| Programme continuation | LIVE_PROVEN | 17E 4-stage proof on real bridge; restart/dup/failure held-outs |
|
||||
| Event-driven waits (builder) | LIVE_PROVEN | T15 15Q real model build; T17E async stage |
|
||||
| Event-driven waits (CI) | LIVE_PROVEN | T16 16D deadline path ×2 + T17 natural wake (mode=event, 112.5s) |
|
||||
| Event replay (before-registration) | TEST_PROVEN + production-realistic exercise | 17C 4.6s vs real completed CI; natural-cycle confirmation: the 17Z wake arrived mid-wait, replay path itself not yet hit naturally |
|
||||
| Cross-user bridge delivery | LIVE_PROVEN | 17C repro as runner user (2.05s) + production deploys |
|
||||
| Provider boundary | TEST_PROVEN (structural guard) + observed loud-failure | 16F/G suites; no silent fallback observed in production runs |
|
||||
| Builder cannot self-certify | LIVE_PROVEN | T14 closure refusals ×2; phase-gated close |
|
||||
| Completion truth evidence-driven | LIVE_PROVEN | contract checkpoints; verify_test_truth refusals |
|
||||
| Interruption/resume | LIVE_PROVEN | T14E SIGKILL; 17E restart held-outs |
|
||||
| Impact preview (visible, pre-build) | ABSENT → T18 | nothing renders structured impact before build today |
|
||||
| Structural project model (first-class) | PARTIAL | stores are spine-shaped (entities/rels) but projects model CONTRACTS, not architecture; no domain/subsystem/interface nesting for arbitrary projects |
|
||||
| Structural queries/impact closure for projects | PARTIAL | spine.impact_frontier exists for Rune; project stores lack query API |
|
||||
| Research→Build Basis (durable, versioned) | ABSENT → T18 | research output today stays in transient context/artifacts |
|
||||
| Research-depth decision at construction entry | PARTIAL | ADAPT routes task classes; no explicit construction-stage research gate |
|
||||
| OUTWARD prior-art-first pass | PARTIAL | stage exists in Research Core; not strengthened to precedent-first ordering |
|
||||
| Schema versioning + consent-gated migration | PARTIAL | stores carry schema_version=1; no migration machinery/consent flow |
|
||||
| Environment/runtime model | PARTIAL | deployment_surface exists in Spine; project scaffolds don't model envs |
|
||||
| Workspace lifecycle (worktree per contract) | PARTIAL | BuildManager worktrees exist (legacy adapter); not wired to contracts |
|
||||
| Forge abstraction | ABSENT (Gitea-coupled scripts) | deploy/qualify call Gitea API directly |
|
||||
| Structural drift for project scaffolds | PARTIAL | drift_resume (16P) covers checkpoint-vs-reality; no file/entity-level drift detection |
|
||||
| Graph projection for visualisation | ABSENT → T18 | Spine has snapshots; no stable read-only graph projection |
|
||||
| Memory/Substrate | ABSENT (BY DESIGN — out of scope) | rule L |
|
||||
|
||||
## V1 North Star gap matrix (this tranche's targets)
|
||||
|
||||
| North Star capability | Gap | T18 action |
|
||||
|---|---|---|
|
||||
| Research-led construction (early research, Build Basis) | ABSENT Build Basis | 18C: build_basis.py + ADAPT depth hook |
|
||||
| OUTWARD precedent-first | PARTIAL | 18D: strengthen stage contract + real research run |
|
||||
| First-class structural Scaffold | PARTIAL (contract-shaped) | 18E: structural_model.py over structural_store core |
|
||||
| Structure→scope→governance | PARTIAL | 18F: queries + impact closure feeding contracts |
|
||||
| Visible impact preview | ABSENT | 18G: impact_preview projection |
|
||||
| Structural drift | PARTIAL | 18H: extend drift_resume with entity-level checks |
|
||||
| Schema versioning + consent | PARTIAL | 18I: migration + consent flow + tests |
|
||||
| Environment/runtime model | PARTIAL | 18J: env entities + verification distinction |
|
||||
| Workspace lifecycle | PARTIAL (legacy adapter) | 18K: workspace contract on the BuildManager adapter |
|
||||
| Forge boundary | ABSENT | 18L: forge.py adapter interface (Gitea impl) |
|
||||
| Full route convergence | PARTIAL (classify shared; backends differ) | 18M: audit + smallest fix |
|
||||
| Generalisation (not phrase list) | TEST_PROVEN | 18N: adversarial paraphrase families |
|
||||
| Self-scaffold | PARTIAL (contracts only) | 18O: structural model of Scaffolding itself |
|
||||
| Upgrade-via-Scaffold proof | ABSENT | 18P: one real upgrade driven by the scaffold |
|
||||
| Graph projection | ABSENT | 18Q: versioned read-only projection |
|
||||
|
||||
## T19 additions (post-T18 classifications)
|
||||
|
||||
| Capability | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Project/Scaffold Registry | TEST_PROVEN | registry tests; boundaries proven (no truth in index, no Spine leak) |
|
||||
| /build governance fail-closed | LIVE_PROVEN | 19A: T18 degradation paths removed; failure-path held-outs |
|
||||
| Capability Ledgers | TEST_PROVEN | lifecycle+history+packet tests |
|
||||
| Surgical context packets | LIVE_PROVEN | 19R: packet bound exactly 1 file for a real change |
|
||||
| Behaviour/Test Specs + hardening lifecycle | TEST_PROVEN | ledger suites incl. generation history |
|
||||
| Selective invalidation | TEST_PROVEN | 19S-2/5/9/10 held-outs |
|
||||
| Predictive vs descriptive impact | TEST_PROVEN | delta persistence tests |
|
||||
| Post-change obligations | TEST_PROVEN | machine-derived from structural truth |
|
||||
| /testing backend + aggregate | TEST_PROVEN + LIVE (19R) | testing_mode suites; command wired in real TUI |
|
||||
| Observation→closure (user-confirm gate) | TEST_PROVEN | closure requires retest AND user confirm |
|
||||
| Forge failure never rewrites truth | TEST_PROVEN | 19S-14 held-out |
|
||||
| Self-hosted T19 change | LIVE_PROVEN | contract 19r-testing-command, 11-step scaffold-driven flow |
|
||||
|
||||
## Kernel exceptions required (rule B record)
|
||||
|
||||
None yet. Every T18 plan so far EXTENDS (new modules consuming
|
||||
structural_store) rather than modifying frozen kernel behavior. Any
|
||||
exception discovered during implementation will be recorded here with
|
||||
evidence that the V1 requirement could not be met otherwise.
|
||||
@@ -0,0 +1,153 @@
|
||||
# SCAFFOLDING V1 DEFINITION (T18 §18B)
|
||||
|
||||
_Plane-English + technical definition of Scaffolding as it ACTUALLY
|
||||
exists at tranche T18 start (master 29570f4). Companion artifact:
|
||||
SCAFFOLDING_IMPLEMENTATION_AUDIT (evidence classification per claim)._
|
||||
|
||||
## What Scaffolding is (plain English)
|
||||
|
||||
Scaffolding is Rune's **construction authority**: the governed process
|
||||
by which software gets built or changed. It takes an authorized need,
|
||||
classifies what kind of construction it is, proportionally governs the
|
||||
work through a durable contract (with legal lifecycle transitions),
|
||||
derives scope from structural reality, drives builders, waits on events
|
||||
(not sleeps), refuses self-certified completion, verifies from
|
||||
canonical reality, and records evidence on durable objects so any
|
||||
future session can resume without a transcript.
|
||||
|
||||
It is NOT a scheduler, NOT a memory system, NOT a provider selector,
|
||||
NOT a research engine, NOT a release system. It is the layer that
|
||||
decides *whether, how, and with what obligations* construction happens
|
||||
— and whether it actually finished.
|
||||
|
||||
## What it owns
|
||||
|
||||
- **Construction classification** (`construction_mode.classify_mode` —
|
||||
the ONE classifier: NOT_CONSTRUCTION / LOCAL_CHANGE /
|
||||
EXISTING_PROJECT_CHANGE / PROJECT_BUILD / UNKNOWN; never silently
|
||||
chooses; unknown surfaces probe questions)
|
||||
- **Conversational interpretation** (`construction_intent.py` —
|
||||
meaning/referents/authorization; DISCUSSION→PROPOSAL→USER_APPROVED;
|
||||
feeds the ONE classifier, never deepens)
|
||||
- **Project Scaffold + Contracts** (`scaffold.py` — legal phase map
|
||||
draft→approved→building→verifying→…→closed/failed/abandoned; scope;
|
||||
obligations; checkpoints; predicted-vs-observed at close)
|
||||
- **Programme continuation** (`programme_continuation.py` —
|
||||
run_construction_programme: stages 1:1 to contracts, persist→canary→
|
||||
next; "task done ≠ programme done")
|
||||
- **Impact/scope machinery** (construction_decision, completeness
|
||||
gates, affected-surface propagation)
|
||||
- **Stage guards** (`stage_guards.py` — readiness blocks, liveness
|
||||
proves progress, exit canary proves effect; verify_test_truth refuses
|
||||
false greens)
|
||||
- **Failure taxonomy** (`failure_recovery.py` — 7 classes incl.
|
||||
PROVIDER_UNAVAILABLE; retry budgets; PERMANENT/APPROVAL never retry)
|
||||
- **Builder interface** (`construction_interface.py` — ONE builder
|
||||
boundary; JSON envelopes; builder identity recorded-but-inert;
|
||||
discover_active_contract; capabilities())
|
||||
|
||||
## What it does NOT own
|
||||
|
||||
- **Provider/model selection** (provider layer owns; ambient context
|
||||
via `provider_context.require_model`; structural guard test)
|
||||
- **Research competence** (Research Core owns; Scaffolding invokes and
|
||||
consumes — see 18C integration)
|
||||
- **Asynchronous wait/resume machinery** (W/R event bridge owns;
|
||||
`event_wait.py` is the repo-side adapter: event drives continuation,
|
||||
watchdog detects failure, durable record is truth)
|
||||
- **Release/deployment** (deploy_stage/promotion/qualify + Gitea
|
||||
workflows own it; Scaffolding records evidence)
|
||||
- **Rune's structural self-model** (Spine owns — `spine.py` over
|
||||
`structural_store.py`; Scaffolding stores use the SAME generic core)
|
||||
- **Long-horizon relevance/memory** (future Substrate — not built;
|
||||
seam exists: construction_projection + construction_commands)
|
||||
- **The TUI** (client; sends text, renders projections)
|
||||
|
||||
## How the flows work
|
||||
|
||||
**New build (PROJECT_BUILD)**: conversational/explicit intent →
|
||||
classify_mode (+ project-existence evidence) → activate() bootstraps a
|
||||
provisional Project Scaffold (identity before repository, UNKNOWN
|
||||
fields explicit) → contract draft→approved (user provenance) →
|
||||
building (builder under event wait) → verifying (exit canary;
|
||||
independent evidence) → closed with predicted-vs-observed.
|
||||
|
||||
**Existing-project change**: intent → classify (scoped/file/target
|
||||
evidence; proportional) → attach to existing authority → light or full
|
||||
contract by depth → same lifecycle, proportionate ceremony.
|
||||
|
||||
**Research-led construction (V1 target — CORRECTED 2026-09-15, Nick):**
|
||||
|
||||
> THE RESEARCH PLAN ITSELF IS CONTEXTUALLY GENERATED. There is NO
|
||||
> permanent research-topic checklist. For every request:
|
||||
> REQUEST/INTENT → understand target/current reality → identify material
|
||||
> unknowns/decisions/risks → generate a context-specific research plan →
|
||||
> perform proportional INWARD/OUTWARD/deeper research. OUTWARD asks what
|
||||
> already exists relevant to THIS specific intent (adopt/adapt/combine/
|
||||
> reject/gaps/innovation). The categories researched are selected from
|
||||
> context; deeper technical research is driven by unresolved questions
|
||||
> and consequence level, never performed mechanically.
|
||||
|
||||
`build_basis.generate_research_plan()` implements this: topics are
|
||||
synthesized from THIS intent's material unknowns/decisions/risks;
|
||||
the held-out suite proves unrelated intents produce materially
|
||||
different plans and that no canned category list exists.
|
||||
|
||||
**Future upgrades (V1 target)**: authorized need → research-depth
|
||||
decision (ADAPT) → Research Core pass → durable Build Basis →
|
||||
structural Scaffold consult/construct → impact closure + visible
|
||||
preview → contract with structurally-derived scope → isolated
|
||||
workspace → governed build/verify → merge/release → structural
|
||||
Scaffold updated. (Parts of this chain are new in T18 — see audit.)
|
||||
|
||||
**Interruption/resume**: SIGKILL-safe. Durable contract + programme
|
||||
records + wait records are the truth; fresh process resumes from
|
||||
identity alone; duplicate signals idempotent (live-proven T14E/T17E).
|
||||
|
||||
**Deployment/release participation**: Scaffolding does not run the
|
||||
pipeline; it consumes qualified CI/deploy evidence, records deployed
|
||||
identity on contracts, and refuses closure without it where contracted.
|
||||
|
||||
## T19 layer model (the general engineering system)
|
||||
|
||||
> "The Scaffold should let the LLM operate with surgical precision
|
||||
> locally, while preserving global awareness of what else must be
|
||||
> checked or changed."
|
||||
>
|
||||
> "Lower active cognitive load without losing the whole idea."
|
||||
|
||||
- **STRUCTURAL MODEL** tells Rune what the project contains / how it connects.
|
||||
- **CAPABILITY LEDGER** provides compact local re-entry context (stable IDs
|
||||
across moves; history preserved through supersede/retire).
|
||||
- **BEHAVIOUR VALIDATION LEDGER** tells Rune what intended behaviour is
|
||||
actually confirmed — generation-aware, selective invalidation, automated
|
||||
green never implies USER_CONFIRMED.
|
||||
- **PROJECT REGISTRY** tells Rune which Scaffolds/projects exist
|
||||
(discovery/index only — not structural truth, not Spine).
|
||||
- **ITERATION ENGINE** (predictive→descriptive impact, impact-delta
|
||||
records, machine-derived obligations) makes quicksand protection
|
||||
STANDARD behaviour of every construction route.
|
||||
- **/TESTING** is a dependency-aware hardening programme: dynamic queue,
|
||||
fix-for-B-may-reopen-A, closure requires retest AND user confirmation.
|
||||
- **FORGE** provides workflow/history projection (failure never rewrites
|
||||
canonical validation truth).
|
||||
- **FUTURE SUBSTRATE** will coordinate sequencing/current focus/pause-
|
||||
resume but does NOT own structural or validation truth.
|
||||
|
||||
## How Spine relates
|
||||
|
||||
Spine = Rune's canonical structural self-model (structural_store
|
||||
generic core, maturity ladder, impact_frontier). Scaffolding's project
|
||||
stores share the SAME generic core (zero Rune constants) — one graph
|
||||
discipline, two scopes: Spine models Rune; project Scaffolds model
|
||||
their projects. No duplicate truth: a project's structural model lives
|
||||
in its own store; Rune's lives in Spine. Later retrofit will modernise
|
||||
Spine using the discipline V1 generalises (18K relationship).
|
||||
|
||||
## How Substrate will interact (without owning internals)
|
||||
|
||||
Read: versioned outbound projection (construction_projection,
|
||||
SCHEMA_VERSION=1). Write: bounded validated commands
|
||||
(construction_commands — 6 commands, lifecycle-validated, idempotent,
|
||||
no direct storage mutation). Substrate asks; Scaffolding decides.
|
||||
Memory graph explicitly out of scope (T18 rule L).
|
||||
@@ -0,0 +1,73 @@
|
||||
# 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` — PIN `RUNE_WR_ROOT`: unpinned consumers get a loud warning, never a silent split-brain) | waits/events/leases/resumes JSON |
|
||||
| Programme continuation | `src/rune/programme_continuation.py` (WIRED T17: `run_construction_programme` binds stages to Scaffolding contracts) | scaffold store `programmes/` |
|
||||
| Conversational construction | `src/rune/construction_intent.py` (meaning/referents/authorization) → `construction_mode.classify_mode` (the ONE classifier) | intents are transient; contracts durable |
|
||||
| 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: WIRED (T17 17D — run_construction_programme
|
||||
is the canonical multi-stage driver; 17E live-proven).
|
||||
- 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`.
|
||||
@@ -11,24 +11,22 @@
|
||||
"NEEDS_REVALIDATION",
|
||||
"DEFERRED_FUTURE"
|
||||
],
|
||||
"live_defects": [
|
||||
324
|
||||
],
|
||||
"live_defects": [],
|
||||
"records": [
|
||||
{
|
||||
"number": 324,
|
||||
"title": "Engine defect: checkpoint writer fails deterministically on aborted worker sessions (MessageAbortedError -> empty checkpoint shells)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "FIXED_VERIFIED",
|
||||
"labels": [
|
||||
"bug",
|
||||
"custodian",
|
||||
"life",
|
||||
"memory"
|
||||
],
|
||||
"live_defect": true,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 defect no longer reproduces: zero MessageAbortedError in current logs (incl. active worker), six most recent session checkpoints full documents (111-396 lines); verified by present-absence 2026-09-19"
|
||||
},
|
||||
{
|
||||
"number": 316,
|
||||
@@ -433,20 +431,20 @@
|
||||
"number": 163,
|
||||
"title": "Temporal graph backend spike: Neo4j vs FalkorDB (LadybugDB experimental; Graphiti as reference)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "NEEDS_REVALIDATION",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"labels": [
|
||||
"P1 \u2014 high",
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "run the backend spike; compare on same corpus/query set; record evidence-backed recommendation"
|
||||
},
|
||||
{
|
||||
"number": 162,
|
||||
"title": "Qdrant associative-memory projection spike (dense/sparse/multivector + ColBERT late-interaction reranking)",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "NEEDS_REVALIDATION",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "SUPERSEDED",
|
||||
"labels": [
|
||||
"P1 \u2014 high",
|
||||
"custodian",
|
||||
@@ -454,8 +452,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 scope carried verbatim by #316 (ADAPT-1 B3-v2); single-tracker rule"
|
||||
},
|
||||
{
|
||||
"number": 159,
|
||||
@@ -478,8 +476,8 @@
|
||||
{
|
||||
"number": 158,
|
||||
"title": "Generated wiring-status / architecture-truth report",
|
||||
"tracker_state": "open",
|
||||
"reconciliation_state": "VALID_OPEN",
|
||||
"tracker_state": "closed",
|
||||
"reconciliation_state": "SUPERSEDED",
|
||||
"labels": [
|
||||
"P2 \u2014 medium",
|
||||
"growth",
|
||||
@@ -489,8 +487,8 @@
|
||||
"ready"
|
||||
],
|
||||
"live_defect": false,
|
||||
"last_revalidated": "2026-09-11",
|
||||
"next_action": ""
|
||||
"last_revalidated": "2026-09-19",
|
||||
"next_action": "none \u2014 scope carried verbatim by #315 (ADAPT-1 B2-v2); single-tracker rule"
|
||||
},
|
||||
{
|
||||
"number": 156,
|
||||
@@ -1151,6 +1149,6 @@
|
||||
"next_action": ""
|
||||
}
|
||||
],
|
||||
"last_reconciled": "2026-09-12",
|
||||
"reconciled_by": "PL1 held-out era reconciliation (fresh session 2026-09-12)"
|
||||
}
|
||||
"last_reconciled": "2026-09-19",
|
||||
"reconciled_by": "fresh-session tranche 2026-09-19: template retrofit (spine+plain-English on all open issues), closures #158/#162 (SUPERSEDED) and #324 (FIXED_VERIFIED), research labels applied to #163/#164/#165/#166/#182/#316, stale bug labels removed from #163-#166"
|
||||
}
|
||||
@@ -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": 347,
|
||||
"S110": 75
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.66.23"
|
||||
version = "0.87.0"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"errorCount": 120,
|
||||
"warningCount": 465,
|
||||
"errorCount": 117,
|
||||
"warningCount": 454,
|
||||
"informationCount": 0
|
||||
}
|
||||
+33
-28
@@ -1,29 +1,34 @@
|
||||
{
|
||||
"typeCheckingMode": "standard",
|
||||
"pythonVersion": "3.14",
|
||||
"reportMissingImports": "warning",
|
||||
"reportMissingModuleSource": "none",
|
||||
"reportMissingTypeStubs": "none",
|
||||
"reportUnknownArgumentType": "none",
|
||||
"reportUnknownParameterType": "none",
|
||||
"reportUnknownVariableType": "none",
|
||||
"reportUnknownMemberType": "none",
|
||||
"reportMissingTypeArgument": "none",
|
||||
"reportUntypedFunctionDecorator": "none",
|
||||
"reportUntypedClassDecorator": "none",
|
||||
"reportUntypedBaseClass": "none",
|
||||
"reportPrivateUsage": "none",
|
||||
"reportOptionalMemberAccess": "warning",
|
||||
"reportUnnecessaryIsInstance": "warning",
|
||||
"reportUnnecessaryComparison": "warning",
|
||||
"reportUnnecessaryTypeIgnoreComment": "warning",
|
||||
"reportPossiblyUnboundVariable": "error",
|
||||
"reportUndefinedVariable": "error",
|
||||
"reportAttributeAccessIssue": "warning",
|
||||
"include": ["src/rune"],
|
||||
"exclude": [
|
||||
"src/rune/__pycache__",
|
||||
"**/node_modules",
|
||||
"**/__pycache__"
|
||||
]
|
||||
}
|
||||
"typeCheckingMode": "standard",
|
||||
"pythonVersion": "3.14",
|
||||
"reportMissingImports": "warning",
|
||||
"reportMissingModuleSource": "none",
|
||||
"reportMissingTypeStubs": "none",
|
||||
"reportUnknownArgumentType": "none",
|
||||
"reportUnknownParameterType": "none",
|
||||
"reportUnknownVariableType": "none",
|
||||
"reportUnknownMemberType": "none",
|
||||
"reportMissingTypeArgument": "none",
|
||||
"reportUntypedFunctionDecorator": "none",
|
||||
"reportUntypedClassDecorator": "none",
|
||||
"reportUntypedBaseClass": "none",
|
||||
"reportPrivateUsage": "none",
|
||||
"reportOptionalMemberAccess": "warning",
|
||||
"reportUnnecessaryIsInstance": "warning",
|
||||
"reportUnnecessaryComparison": "warning",
|
||||
"reportUnnecessaryTypeIgnoreComment": "warning",
|
||||
"reportPossiblyUnboundVariable": "error",
|
||||
"reportUndefinedVariable": "error",
|
||||
"reportAttributeAccessIssue": "warning",
|
||||
"include": [
|
||||
"src/rune"
|
||||
],
|
||||
"exclude": [
|
||||
"src/rune/__pycache__",
|
||||
"**/node_modules",
|
||||
"**/__pycache__"
|
||||
],
|
||||
"extraPaths": [
|
||||
"scripts"
|
||||
]
|
||||
}
|
||||
+7
-7
@@ -1,5 +1,5 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
|
||||
# uv pip compile pyproject.toml -o /tmp/req_new.txt --extra dev --extra memory --python-version 3.11
|
||||
aiohappyeyeballs==2.7.1
|
||||
# via aiohttp
|
||||
aiohttp==3.14.3
|
||||
@@ -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)
|
||||
@@ -34,7 +34,7 @@ httpcore==1.0.9
|
||||
# via httpx
|
||||
httpx==0.28.1
|
||||
# via rune (pyproject.toml)
|
||||
idna==3.19
|
||||
idna==3.20
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
@@ -56,7 +56,7 @@ mdit-py-plugins==0.6.1
|
||||
# via textual
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
multidict==6.8.0
|
||||
multidict==6.9.0
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
@@ -64,13 +64,13 @@ nodeenv==1.10.0
|
||||
# via pyright
|
||||
packaging==26.3
|
||||
# via pytest
|
||||
platformdirs==4.11.8
|
||||
platformdirs==4.11.11
|
||||
# via textual
|
||||
pluggy==1.6.0
|
||||
# via
|
||||
# diff-cover
|
||||
# pytest
|
||||
propcache==0.5.2
|
||||
propcache==0.5.4
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
@@ -118,5 +118,5 @@ typing-extensions==4.16.0
|
||||
# textual-autocomplete
|
||||
websockets==17.1
|
||||
# via rune (pyproject.toml)
|
||||
yarl==1.24.5
|
||||
yarl==1.25.1
|
||||
# via aiohttp
|
||||
Whitespace-only changes.
@@ -0,0 +1,480 @@
|
||||
"""await_intent.py — ASYNC CORRECTIVE PATCH: intent normalization at the
|
||||
execution boundary.
|
||||
|
||||
PRINCIPLE: MODEL OUTPUT = INTENT. RUNE CONTROLLER = EXECUTION SEMANTICS.
|
||||
|
||||
The model may express continuation intent in any quality — `await_operation
|
||||
(deadline_secs=1200)`, `await-operation [op_type=master_ci, sha=X]`,
|
||||
"sleep 300 then check CI", "poll until done". When a matching
|
||||
PendingOperation exists (or the deterministic resolver can derive the
|
||||
operation truth from git/Gitea state), the controller executes the
|
||||
CANONICAL continuation and discards model-supplied scheduling policy.
|
||||
|
||||
Authority provenance for timing:
|
||||
USER_EXPLICIT / EXTERNAL_CONTRACT / TEST_HARNESS /
|
||||
GOVERNED_OPERATION_POLICY -> authoritative, retained
|
||||
MODEL_INVENTED -> non-authoritative, ignored
|
||||
|
||||
This module owns NO storage: it reads the existing W/R wait store
|
||||
(rune.event_wait) and git/Gitea state. The canonical continuation remains
|
||||
scripts/ci_event_wait.py run() (the T21.5 single-logical-await).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(_SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
_REPO_SRC = str(_SCRIPT_DIR.parent / "src")
|
||||
if _REPO_SRC not in sys.path:
|
||||
sys.path.insert(0, _REPO_SRC)
|
||||
|
||||
WR_BRIDGE = Path(os.environ.get("RUNE_WR_ROOT", "/mnt/workdrive/wr-bridge"))
|
||||
REPO = Path("/mnt/workdrive/issue161-harness")
|
||||
GIT_TIMEOUT = 10
|
||||
|
||||
# Default policy deadline per operation type — GOVERNED_OPERATION_POLICY.
|
||||
# The model's numbers never become policy; these are the controller's.
|
||||
POLICY_DEADLINES = {
|
||||
"branch_ci": 1800,
|
||||
"pull_request_checks": 1800,
|
||||
"master_ci": 1800,
|
||||
"deploy": 900,
|
||||
"release": 900,
|
||||
}
|
||||
|
||||
VALID_OP_TYPES = ("branch_ci", "pull_request_checks", "master_ci",
|
||||
"deploy", "release")
|
||||
|
||||
|
||||
# ── timing provenance ───────────────────────────────────────────────────────
|
||||
|
||||
def classify_timing_provenance(model_deadline: Any = None,
|
||||
user_deadline: Any = None,
|
||||
contract_deadline: Any = None) -> tuple[int, str]:
|
||||
"""Resolve the effective deadline + its provenance.
|
||||
|
||||
Authority order: USER_EXPLICIT > EXTERNAL_CONTRACT > TEST_HARNESS >
|
||||
GOVERNED_OPERATION_POLICY. MODEL_INVENTED values are recorded but
|
||||
NEVER used as policy.
|
||||
"""
|
||||
if model_deadline is not None:
|
||||
try:
|
||||
model_deadline = int(model_deadline)
|
||||
except (TypeError, ValueError):
|
||||
model_deadline = None
|
||||
if user_deadline is not None:
|
||||
return int(user_deadline), "USER_EXPLICIT"
|
||||
if contract_deadline is not None:
|
||||
return int(contract_deadline), "EXTERNAL_CONTRACT"
|
||||
return (model_deadline or 0), "MODEL_INVENTED" # caller discards this
|
||||
|
||||
|
||||
# ── PendingOperation lookup over the existing W/R wait store ────────────────
|
||||
|
||||
def _load_wait_records() -> list[dict]:
|
||||
"""All durable wait records from the bridge store (read-only)."""
|
||||
out = []
|
||||
waits = WR_BRIDGE / "waits"
|
||||
if not waits.is_dir():
|
||||
return out
|
||||
for f in sorted(waits.glob("*.json")):
|
||||
try:
|
||||
rec = json.loads(f.read_text())
|
||||
rec["_file"] = f.name
|
||||
out.append(rec)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _rec_operation_id(rec: dict) -> str:
|
||||
return str((rec.get("ext") or {}).get("operation_id")
|
||||
or rec.get("operation_id") or "")
|
||||
|
||||
|
||||
def _rec_sha(rec: dict) -> str:
|
||||
return str(rec.get("sha") or "")
|
||||
|
||||
|
||||
def pending_operations(sha: str = "", op_type: str = "") -> list[dict]:
|
||||
"""Pending (non-terminal) operations, optionally filtered."""
|
||||
terminal = {"COMPLETED", "FAILED", "CLOSED", "CANCELLED", "EXPIRED"}
|
||||
out = []
|
||||
for rec in _load_wait_records():
|
||||
status = str(rec.get("status", "")).upper()
|
||||
if status in terminal:
|
||||
continue
|
||||
if sha and _rec_sha(rec) != sha:
|
||||
continue
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def find_op_type_for_sha(sha: str) -> str | None:
|
||||
"""The op_type a prior canonical wait already recorded for this SHA
|
||||
(objective text embeds the type: 'event-driven {op_type} qualification')."""
|
||||
for rec in _load_wait_records():
|
||||
if _rec_sha(rec) != sha:
|
||||
continue
|
||||
obj = str(rec.get("continuation", {}).get("objective", ""))
|
||||
m = re.search(r"event-driven (\w+) qualification", obj)
|
||||
if m and m.group(1) in VALID_OP_TYPES:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
# ── deterministic op_type derivation from repo/Gitea truth ─────────────────
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
try:
|
||||
r = subprocess.run(["git", "-C", str(REPO), *args],
|
||||
capture_output=True, text=True, timeout=GIT_TIMEOUT)
|
||||
return r.stdout.strip()
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def sha_branch(sha: str) -> str:
|
||||
"""Branch(es) containing this commit in the local repo."""
|
||||
return _git("branch", "--all", "--contains", sha)
|
||||
|
||||
|
||||
def sha_on_master(sha: str) -> bool:
|
||||
out = _git("branch", "--contains", sha)
|
||||
return any(l.strip().endswith("master") or l.strip() == "master"
|
||||
for l in out.splitlines())
|
||||
|
||||
|
||||
def open_pr_for_sha(sha: str) -> int | None:
|
||||
"""Is there an OPEN PR whose head SHA is this one? (Gitea API, read-only.)"""
|
||||
try:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import gitea_auth # noqa: E402
|
||||
st, prs = gitea_auth.api("/pulls?state=open&limit=50")
|
||||
if st != 200:
|
||||
return None
|
||||
for p in prs:
|
||||
hs = str(p.get("head") or {}).get("sha") if isinstance(
|
||||
p.get("head"), dict) else None
|
||||
if hs and str(hs).startswith(sha[:12]):
|
||||
return int(p.get("number"))
|
||||
except Exception: # noqa: BLE001 — resolver must never crash the tool
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def derive_op_type(sha: str) -> tuple[str, str]:
|
||||
"""Deterministically derive the canonical op_type for a SHA.
|
||||
|
||||
Returns (op_type, evidence). Order (A-patch §2 amendment):
|
||||
1. POST-PATCH normalized record (ext.intent_normalized=true) — true
|
||||
stored operation truth, wins outright;
|
||||
2. open PR for the sha -> pull_request_checks;
|
||||
3. sha ancestor of master -> master_ci;
|
||||
4. sha on a feature branch -> branch_ci;
|
||||
5. legacy (pre-patch) records only CONFIRM, never override — they
|
||||
were created from model-supplied defaults and cannot be trusted.
|
||||
Never trusts model input.
|
||||
"""
|
||||
for rec in _load_wait_records():
|
||||
if _rec_sha(rec) != sha:
|
||||
continue
|
||||
if (rec.get("ext") or {}).get("intent_normalized"):
|
||||
obj = str(rec.get("continuation", {}).get("objective", ""))
|
||||
m = re.search(r"event-driven (\w+) qualification", obj)
|
||||
if m and m.group(1) in VALID_OP_TYPES:
|
||||
return m.group(1), "normalized_record:" + m.group(1)
|
||||
pr = open_pr_for_sha(sha)
|
||||
if pr:
|
||||
return "pull_request_checks", f"open_pr:#{pr}"
|
||||
if sha_on_master(sha):
|
||||
return "master_ci", "commit_ancestor_of_master"
|
||||
return "branch_ci", "commit_on_feature_branch"
|
||||
|
||||
|
||||
# ── intent normalization ────────────────────────────────────────────────────
|
||||
|
||||
def normalize_action(action: dict, *,
|
||||
model_deadline: Any = None,
|
||||
user_deadline: Any = None,
|
||||
contract_deadline: Any = None,
|
||||
task_id: str = "") -> dict:
|
||||
"""Normalize a model continuation action into the canonical continuation.
|
||||
|
||||
action: {kind: await_operation|sleep_poll|check_status|unknown,
|
||||
sha?: str, operation_id?: str, op_type?: str, raw?: str}
|
||||
task_id: REAL_ASYNC_BRIDGE §9 — when the caller knows its task
|
||||
binding, pending-operation resolution is scoped to operations
|
||||
registered with that task_id; stale global operations are
|
||||
never consulted when a binding exists.
|
||||
Returns:
|
||||
{normalized: bool, action: "ContinuePendingOperation", operation_id,
|
||||
op_type, authority, deadline_secs, deadline_provenance,
|
||||
model_deadline_ignored, model_op_type_ignored, evidence}
|
||||
or {normalized: False, reason: ...} / {normalized: "AMBIGUOUS", candidates}
|
||||
"""
|
||||
sha = str(action.get("sha") or "")
|
||||
op_id = str(action.get("operation_id") or "")
|
||||
model_op_type = action.get("op_type")
|
||||
model_deadline_ignored = model_deadline is not None
|
||||
model_op_type_ignored = model_op_type is not None
|
||||
|
||||
# 0. task binding scope (§9): task-bound operations beat global state.
|
||||
# Stored operation wins over ANY model hint (sha/op_type/deadline).
|
||||
if task_id and not op_id:
|
||||
bound = [r for r in pending_operations()
|
||||
if str(r.get("ext", {}).get("task_id", "")) == task_id]
|
||||
if len(bound) == 1:
|
||||
rec = bound[0]
|
||||
norm = _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored,
|
||||
"task_bound",
|
||||
user_deadline, contract_deadline)
|
||||
norm["sha_for_wait"] = _rec_sha(rec)
|
||||
norm["task_id"] = task_id
|
||||
return norm
|
||||
if len(bound) > 1:
|
||||
return {"normalized": "AMBIGUOUS",
|
||||
"candidates": [_rec_operation_id(c) for c in bound],
|
||||
"reason": f"multiple pending operations bound to task {task_id}"}
|
||||
return {"normalized": False,
|
||||
"reason": f"no pending operation bound to task {task_id}"}
|
||||
|
||||
# 1. explicit operation_id wins
|
||||
if op_id:
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
rec = ew.operation_wait(op_id)
|
||||
if rec is None:
|
||||
# REAL_ASYNC_BRIDGE §10: a FRESH operation has no wait record
|
||||
# yet — registration happens at the park step (ci_event_wait).
|
||||
# For an explicit, well-formed, EXISTING commit id, return the
|
||||
# provisional canonical continuation (policy deadline, own-runs
|
||||
# authority) so the model-facing tool can park on first call.
|
||||
# A typo'd/ghost sha is refused here (A-patch lesson): the
|
||||
# commit must exist in the repository.
|
||||
import re as _re
|
||||
m = _re.fullmatch(r"ci-([0-9a-f]{40})", op_id)
|
||||
if m:
|
||||
sha40 = m.group(1)
|
||||
probe = subprocess.run(
|
||||
["git", "-C", str(REPO), "cat-file", "-e",
|
||||
f"{sha40}^{{commit}}"],
|
||||
capture_output=True, timeout=15)
|
||||
if probe.returncode == 0:
|
||||
return {
|
||||
"normalized": True,
|
||||
"action": "ContinuePendingOperation",
|
||||
"operation_id": op_id,
|
||||
"sha_for_wait": sha40,
|
||||
"op_type": "branch_ci",
|
||||
"authority": "ci_runs",
|
||||
"deadline_secs": POLICY_DEADLINES["branch_ci"],
|
||||
"deadline_provenance": "GOVERNED_OPERATION_POLICY",
|
||||
"model_deadline_ignored": model_deadline_ignored,
|
||||
"model_op_type_ignored": model_op_type_ignored,
|
||||
"evidence": "explicit_id_fresh_registration",
|
||||
}
|
||||
return {"normalized": False,
|
||||
"reason": f"operation_id {op_id} has no wait record"}
|
||||
return _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored, "explicit_id",
|
||||
user_deadline, contract_deadline)
|
||||
|
||||
# 2. resolve by sha / active operation.
|
||||
# H-PATCH H2/H3: the model sha is a HINT. If it doesn't resolve but
|
||||
# unambiguous pending operations exist, the STORED target wins — a
|
||||
# mistyped model sha must never create a ghost operation (the A-patch
|
||||
# live failure: a typo'd sha derived branch_ci for a nonexistent commit
|
||||
# and parked a wait on a ghost).
|
||||
# 2. no explicit operation_id: resolve by sha / pending operations.
|
||||
# H-PATCH: when the model provides NO sha (operation-id-first mode),
|
||||
# resolve from unambiguous pending operations — the model should not
|
||||
# need to reconstruct a 40-char sha to express "wait for it".
|
||||
if not sha:
|
||||
all_pending = pending_operations()
|
||||
if len(all_pending) == 1:
|
||||
rec = all_pending[0]
|
||||
norm = _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored,
|
||||
"auto_resolved_no_sha",
|
||||
user_deadline, contract_deadline)
|
||||
norm["sha_for_wait"] = _rec_sha(rec)
|
||||
return norm
|
||||
if len(all_pending) > 1:
|
||||
return {"normalized": "AMBIGUOUS",
|
||||
"candidates": [_rec_operation_id(c) for c in all_pending],
|
||||
"reason": "multiple pending operations, no sha hint"}
|
||||
return {"normalized": False,
|
||||
"reason": "no pending operations and no sha provided"}
|
||||
|
||||
candidates = pending_operations(sha=sha)
|
||||
if len(candidates) > 1 and not sha:
|
||||
return {"normalized": "AMBIGUOUS",
|
||||
"candidates": [_rec_operation_id(c) for c in candidates],
|
||||
"reason": "multiple pending operations, no target"}
|
||||
if len(candidates) == 1:
|
||||
rec = candidates[0]
|
||||
stored_sha = _rec_sha(rec)
|
||||
norm = _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored, "pending_match",
|
||||
user_deadline, contract_deadline)
|
||||
if sha and stored_sha and sha != stored_sha:
|
||||
norm["model_sha_mismatch"] = {
|
||||
"model_sha": sha, "stored_sha": stored_sha,
|
||||
"resolution": "stored_target_used"}
|
||||
return norm
|
||||
# model sha unresolved: any unambiguous pending op at all? then the
|
||||
# model probably mistyped the sha — use the stored target, log it.
|
||||
all_pending = pending_operations()
|
||||
if len(all_pending) == 1 and sha:
|
||||
rec = all_pending[0]
|
||||
stored_sha = _rec_sha(rec)
|
||||
norm = _canonical_from_record(rec, model_deadline_ignored,
|
||||
model_op_type_ignored,
|
||||
"unresolved_sha_single_pending",
|
||||
user_deadline, contract_deadline)
|
||||
norm["model_sha_mismatch"] = {
|
||||
"model_sha": sha, "stored_sha": stored_sha,
|
||||
"resolution": "stored_target_used"}
|
||||
return norm
|
||||
|
||||
# 3. no stored record: derive truth deterministically
|
||||
if not sha:
|
||||
return {"normalized": False,
|
||||
"reason": "no pending operation and no sha to resolve"}
|
||||
op_type, evidence = derive_op_type(sha)
|
||||
return {
|
||||
"normalized": True,
|
||||
"action": "ContinuePendingOperation",
|
||||
"operation_id": f"ci-{sha}",
|
||||
"op_type": op_type,
|
||||
"authority": "ci_runs" if op_type in ("branch_ci",
|
||||
"pull_request_checks")
|
||||
else "deploy_qualify",
|
||||
"deadline_secs": POLICY_DEADLINES[op_type],
|
||||
"deadline_provenance": "GOVERNED_OPERATION_POLICY",
|
||||
"model_deadline_ignored": model_deadline_ignored,
|
||||
"model_op_type_ignored": model_op_type_ignored,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_from_record(rec: dict, model_deadline_ignored: bool,
|
||||
model_op_type_ignored: bool, how: str,
|
||||
user_deadline: Any, contract_deadline: Any) -> dict:
|
||||
op_id = _rec_operation_id(rec)
|
||||
obj = str(rec.get("continuation", {}).get("objective", ""))
|
||||
m = re.search(r"event-driven (\w+) qualification", obj)
|
||||
recorded = m.group(1) if (m and m.group(1) in VALID_OP_TYPES) else None
|
||||
rec_sha = _rec_sha(rec)
|
||||
if (rec.get("ext") or {}).get("intent_normalized") or how == "explicit_id":
|
||||
# normalized record or explicit id: stored truth wins
|
||||
op_type = recorded or "master_ci"
|
||||
else:
|
||||
# legacy record: git/Gitea derivation outranks the model-era default
|
||||
op_type, _ev = derive_op_type(rec_sha) if rec_sha else \
|
||||
(recorded or "master_ci", "no_sha")
|
||||
deadline, prov = classify_timing_provenance(
|
||||
user_deadline=user_deadline, contract_deadline=contract_deadline)
|
||||
if prov == "MODEL_INVENTED" or not deadline:
|
||||
deadline = POLICY_DEADLINES.get(op_type, 1800)
|
||||
prov = "GOVERNED_OPERATION_POLICY"
|
||||
return {
|
||||
"normalized": True,
|
||||
"action": "ContinuePendingOperation",
|
||||
"operation_id": op_id,
|
||||
"op_type": op_type,
|
||||
"authority": "ci_runs" if op_type in ("branch_ci",
|
||||
"pull_request_checks")
|
||||
else "deploy_qualify",
|
||||
"deadline_secs": deadline,
|
||||
"deadline_provenance": prov,
|
||||
"model_deadline_ignored": model_deadline_ignored,
|
||||
"model_op_type_ignored": model_op_type_ignored,
|
||||
"matched_by": how,
|
||||
}
|
||||
|
||||
|
||||
def normalize_bash(command: str, *, sha_hint: str = "") -> dict | None:
|
||||
"""Recognize sleep/check/poll intent aimed at a known pending operation.
|
||||
|
||||
Returns a normalization record if the command's semantic intent is
|
||||
continuation of a known external operation; None if the command is
|
||||
legitimate local timing (short sync, user delay, backoff) with no
|
||||
pending operation to continue. NEVER returns an executed sleep.
|
||||
"""
|
||||
if not command:
|
||||
return None
|
||||
cmd = command.strip()
|
||||
# long sleep + (status-check language or an explicit sha hint)
|
||||
sleep_m = re.search(r"\bsleep\s+(\d{2,5})\b", cmd)
|
||||
if not sleep_m:
|
||||
return None
|
||||
secs = int(sleep_m.group(1))
|
||||
check_intent = bool(re.search(
|
||||
r"\b(ci|deploy|release|status|check|poll|pipeline|run)\b", cmd,
|
||||
re.IGNORECASE))
|
||||
sha = sha_hint or ""
|
||||
m = re.search(r"\b([0-9a-f]{40}|[0-9a-f]{12})\b", cmd)
|
||||
if m:
|
||||
sha = m.group(1)
|
||||
if not check_intent and not sha:
|
||||
return None # plain sleep, no continuation intent
|
||||
cands = pending_operations(sha=sha) if sha else pending_operations()
|
||||
if not cands and not sha:
|
||||
return None
|
||||
if len(cands) > 1:
|
||||
return {"normalized": "AMBIGUOUS",
|
||||
"candidates": [_rec_operation_id(c) for c in cands],
|
||||
"raw_command_kind": "sleep_poll"}
|
||||
rec = cands[0] if cands else None
|
||||
if rec is not None:
|
||||
norm = _canonical_from_record(rec, True, False, "sleep_poll_intent",
|
||||
None, None)
|
||||
norm["sha_for_wait"] = _rec_sha(rec)
|
||||
elif sha:
|
||||
op_type, evidence = derive_op_type(sha)
|
||||
norm = {
|
||||
"normalized": True, "action": "ContinuePendingOperation",
|
||||
"operation_id": f"ci-{sha}", "op_type": op_type,
|
||||
"authority": "ci_runs" if op_type in ("branch_ci",
|
||||
"pull_request_checks")
|
||||
else "deploy_qualify",
|
||||
"deadline_secs": POLICY_DEADLINES[op_type],
|
||||
"deadline_provenance": "GOVERNED_OPERATION_POLICY",
|
||||
"model_deadline_ignored": True, "model_op_type_ignored": False,
|
||||
"evidence": evidence, "sha_for_wait": sha,
|
||||
}
|
||||
else:
|
||||
return None
|
||||
norm["raw_command_kind"] = "sleep_poll"
|
||||
norm["raw_sleep_seconds_suppressed"] = secs
|
||||
return norm
|
||||
|
||||
|
||||
def emit_normalized_record(record: dict) -> None:
|
||||
"""Compact structured observability — one JSONL line per normalization.
|
||||
No payloads, no secrets."""
|
||||
try:
|
||||
log = _SCRIPT_DIR.parent / "store" / "await_intent_log.jsonl"
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
slim = {k: record.get(k) for k in (
|
||||
"normalized", "action", "operation_id", "op_type", "authority",
|
||||
"deadline_secs", "deadline_provenance", "model_deadline_ignored",
|
||||
"model_op_type_ignored", "matched_by", "evidence",
|
||||
"raw_command_kind", "raw_sleep_seconds_suppressed", "reason",
|
||||
"candidates") if k in record}
|
||||
slim["at"] = __import__("time").strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", __import__("time").gmtime())
|
||||
with open(log, "a") as f:
|
||||
f.write(json.dumps(slim, sort_keys=True) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""await_intent_cli.py — CLI surface for the intent normalization layer.
|
||||
|
||||
Used by the await-operation harness tool so the MODEL's scheduling hints
|
||||
never reach the wait engine. Prints one JSON line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import await_intent # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
n = sub.add_parser("normalize", help="normalize a continuation intent")
|
||||
n.add_argument("--sha", default="")
|
||||
n.add_argument("--operation-id", default="")
|
||||
n.add_argument("--task-id", default="",
|
||||
help="§9 task binding: scope pending resolution to "
|
||||
"operations registered for this task")
|
||||
n.add_argument("--model-op-type", default=None)
|
||||
n.add_argument("--model-deadline", default=None)
|
||||
n.add_argument("--user-deadline", default=None,
|
||||
help="USER_EXPLICIT deadline (authoritative)")
|
||||
n.add_argument("--contract-deadline", default=None,
|
||||
help="EXTERNAL_CONTRACT deadline (authoritative)")
|
||||
rb = sub.add_parser("route-bash",
|
||||
help="route a Bash command expressing wait intent")
|
||||
rb.add_argument("--command", required=True)
|
||||
rb.add_argument("--sha-hint", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.cmd == "route-bash":
|
||||
record = await_intent.normalize_bash(args.command,
|
||||
sha_hint=args.sha_hint)
|
||||
if record is None:
|
||||
print(json.dumps({"normalized": False,
|
||||
"reason": "no external continuation intent"}))
|
||||
return 0
|
||||
if record.get("normalized") is True:
|
||||
# the compiled wait needs the SHA: derive from operation_id
|
||||
oid = str(record.get("operation_id", ""))
|
||||
if oid.startswith("ci-"):
|
||||
record["sha_for_wait"] = oid[3:]
|
||||
await_intent.emit_normalized_record(record)
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
return 0
|
||||
|
||||
action = {"kind": "await_operation", "sha": args.sha,
|
||||
"operation_id": args.operation_id,
|
||||
"op_type": args.model_op_type}
|
||||
model_deadline = (int(args.model_deadline)
|
||||
if args.model_deadline else None)
|
||||
user_deadline = (int(args.user_deadline)
|
||||
if args.user_deadline else None)
|
||||
contract_deadline = (int(args.contract_deadline)
|
||||
if args.contract_deadline else None)
|
||||
record = await_intent.normalize_action(
|
||||
action, model_deadline=model_deadline,
|
||||
user_deadline=user_deadline, contract_deadline=contract_deadline,
|
||||
task_id=args.task_id)
|
||||
await_intent.emit_normalized_record(record)
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,588 @@
|
||||
#!/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))
|
||||
sys.path.insert(0, str(_SCRIPT_DIR.parent / "src"))
|
||||
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
import completion_file as cf # noqa: E402
|
||||
|
||||
REPO_ROOT = _SCRIPT_DIR.parent
|
||||
QUALIFY_SCRIPT = _SCRIPT_DIR / "deploy_qualify.py"
|
||||
|
||||
|
||||
def authority_verdict(sha: str = "") -> str:
|
||||
"""ONE-SHOT canonical Gitea verdict via deploy_qualify.py — the only
|
||||
completion truth. Never parses the event payload.
|
||||
|
||||
H-PATCH H4: the target sha is passed EXPLICITLY (as the subprocess's
|
||||
--sha argument) — ambient DEPLOY_SHA can no longer change which
|
||||
operation is verified. The env var remains only as a CLI backward
|
||||
compat for deploy_qualify.py invoked standalone."""
|
||||
cmd = [sys.executable, str(QUALIFY_SCRIPT)]
|
||||
if sha:
|
||||
cmd += ["--sha", sha]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env={k: v for k, v in os.environ.items() if k != "DEPLOY_SHA"}
|
||||
if sha else dict(os.environ, PYTHONPATH=""),
|
||||
)
|
||||
out = (proc.stdout or "").strip().splitlines()
|
||||
return (out[-1] if out else "pending").strip()
|
||||
|
||||
|
||||
# ── T21.5 §4: operation-specific completion authority ──────────────────────
|
||||
#
|
||||
# T21.4 root cause: the wait loop had ONE hard-coded authority
|
||||
# (deploy qualification = master-ref CI) while operations waited on
|
||||
# branch CI, PR checks, or merges. Wrong authority => nonterminal
|
||||
# returns => the MODEL had to re-arm (the generator of the sleeps).
|
||||
# The wait verifier MUST match the operation being waited for.
|
||||
|
||||
OPERATION_SPECS = {
|
||||
# op_type: {
|
||||
# workflow_path: Actions run path filter (None = any),
|
||||
# head_branches: head-branch filter (None = any),
|
||||
# authority: which canonical verifier decides completion,
|
||||
# event_types: expected wake event types,
|
||||
# terminal: terminal verdicts, failure: failure verdicts,
|
||||
# }
|
||||
"branch_ci": {
|
||||
"workflow_path": "ci.yml",
|
||||
"head_branches": None,
|
||||
"authority": "ci_runs", # any completed run for this SHA
|
||||
"event_types": ("ci_completed",),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
"pull_request_checks": {
|
||||
"workflow_path": None, # all PR-triggered runs for SHA
|
||||
"head_branches": None,
|
||||
"authority": "ci_runs",
|
||||
"event_types": ("ci_completed",),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
"master_ci": {
|
||||
"workflow_path": "ci.yml",
|
||||
"head_branches": ("master",),
|
||||
"authority": "deploy_qualify", # full two-leg qualification
|
||||
"event_types": ("ci_completed",),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
"deploy": {
|
||||
"workflow_path": "deploy.yml",
|
||||
"head_branches": ("master",),
|
||||
"authority": "deploy_qualify",
|
||||
"event_types": ("ci_completed", "deploy_completed"),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
"release": {
|
||||
"workflow_path": None,
|
||||
"head_branches": ("master",),
|
||||
"authority": "deploy_qualify",
|
||||
"event_types": ("ci_completed", "release_completed"),
|
||||
"terminal": ("success",),
|
||||
"failure": ("failure", "cancelled"),
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_OP_TYPE = "master_ci"
|
||||
|
||||
|
||||
def normalize_verdict(verdict: str) -> str:
|
||||
"""T21.5 §18 audit fix: pending-class verdicts are ALL nonterminal.
|
||||
deploy_qualify emits "pending (probe failed)" for a transient probe
|
||||
failure — that is pending, never a terminal answer. The wait loop
|
||||
must not return control to the model over a string variant."""
|
||||
v = (verdict or "").strip().lower()
|
||||
if v.startswith("pending"):
|
||||
return "pending"
|
||||
return (verdict or "").strip()
|
||||
|
||||
|
||||
def op_spec(op_type: str) -> dict:
|
||||
spec = OPERATION_SPECS.get(op_type)
|
||||
if spec is None:
|
||||
raise ValueError(
|
||||
f"unknown op_type {op_type!r} — must be one of "
|
||||
f"{sorted(OPERATION_SPECS)} (the wait verifier must match "
|
||||
"the operation being waited for)")
|
||||
return spec
|
||||
|
||||
|
||||
def op_authority_verdict(op_type: str, sha: str) -> str:
|
||||
"""Canonical completion verdict FOR THIS OPERATION TYPE.
|
||||
|
||||
ci_runs — the op's own Actions runs for this exact SHA: every
|
||||
required workflow run completed; terminal iff every
|
||||
run of the op's workflow set concluded success
|
||||
(branch/PR ops are their own truth; deploy
|
||||
qualification is NOT their authority).
|
||||
deploy_qualify — the two-leg deploy qualification (master CI +
|
||||
required-checks) via deploy_qualify.py.
|
||||
"""
|
||||
spec = op_spec(op_type)
|
||||
authority = spec["authority"]
|
||||
if authority == "deploy_qualify":
|
||||
v = normalize_verdict(authority_verdict(sha))
|
||||
if v != "pending":
|
||||
return v
|
||||
# E-PATCH resilience: Gitea's combined commit-status endpoint can
|
||||
# 404 for the newest commit (observed live on auto-version bump
|
||||
# commits — the status object materializes late/never). The runs
|
||||
# ARE the same evidence the two legs read; fall back to the
|
||||
# operation-scoped runs verdict so authority stays resolvable.
|
||||
if _status_endpoint_unreadable(sha):
|
||||
return _ci_runs_verdict(spec, sha)
|
||||
return v
|
||||
if authority == "ci_runs":
|
||||
return _ci_runs_verdict(spec, sha)
|
||||
raise ValueError(f"unknown authority {authority!r} for {op_type!r}")
|
||||
|
||||
|
||||
def _status_endpoint_unreadable(sha: str) -> bool:
|
||||
"""True when the combined commit-status endpoint 404s for the SHA
|
||||
(index lag / never-materialized status object) — the documented
|
||||
fallback trigger, verified against the API once per call."""
|
||||
try:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import gitea_auth # noqa: E402
|
||||
st, _body = gitea_auth.api(f"/commits/{sha}/status")
|
||||
return st == 404
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _ci_runs_verdict(spec: dict, sha: str) -> str:
|
||||
"""Operation-scoped Actions-run verdict (import the API client from
|
||||
deploy_qualify to keep ONE Gitea client)."""
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import deploy_qualify as dq # noqa: E402
|
||||
import gitea_auth # noqa: E402
|
||||
server = os.environ.get("GITEA_SERVER",
|
||||
getattr(gitea_auth, "DEFAULT_SERVER", "")).rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO",
|
||||
getattr(gitea_auth, "DEFAULT_REPO", ""))
|
||||
sha_env = sha # H-PATCH: explicit parameter is the only authority
|
||||
token = gitea_auth.get_token() if gitea_auth.has_token() else ""
|
||||
if not (server and repo and sha_env and token):
|
||||
return "pending" # cannot verify -> not verified (fail closed)
|
||||
runs = dq._fetch_runs(server, repo, sha_env, token)
|
||||
if runs is None:
|
||||
return "pending" # probe failure -> keep waiting
|
||||
wfp = spec["workflow_path"]
|
||||
if wfp is not None:
|
||||
runs = [r for r in runs
|
||||
if str(r.get("path", "")).startswith(wfp + "@")]
|
||||
branches = spec["head_branches"]
|
||||
if branches is not None:
|
||||
runs = [r for r in runs
|
||||
if str(r.get("head_branch", "")) in branches]
|
||||
if not runs:
|
||||
return "pending" # no runs visible yet -> not terminal
|
||||
for r in runs:
|
||||
if r.get("status") != "completed":
|
||||
return "pending" # still running
|
||||
if r.get("conclusion") not in ("success", "skipped"):
|
||||
return "failure" # any non-success conclusion
|
||||
return "success"
|
||||
|
||||
|
||||
def op_ci_alive(op_type: str, sha: str) -> bool:
|
||||
"""Liveness probe FOR THIS OPERATION TYPE: any non-terminal run in
|
||||
the op's run set (failure DETECTOR, never the scheduler)."""
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import deploy_qualify as dq # noqa: E402
|
||||
import gitea_auth # noqa: E402
|
||||
spec = op_spec(op_type)
|
||||
server = os.environ.get("GITEA_SERVER",
|
||||
getattr(gitea_auth, "DEFAULT_SERVER", "")).rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO",
|
||||
getattr(gitea_auth, "DEFAULT_REPO", ""))
|
||||
sha_env = sha # H-PATCH: explicit parameter is the only authority
|
||||
token = gitea_auth.get_token() if gitea_auth.has_token() else ""
|
||||
if not (server and repo and sha_env and token):
|
||||
return False
|
||||
runs = dq._fetch_runs(server, repo, sha_env, token)
|
||||
if not runs:
|
||||
return False
|
||||
wfp = spec["workflow_path"]
|
||||
if wfp is not None:
|
||||
runs = [r for r in runs
|
||||
if str(r.get("path", "")).startswith(wfp + "@")]
|
||||
branches = spec["head_branches"]
|
||||
if branches is not None:
|
||||
runs = [r for r in runs
|
||||
if str(r.get("head_branch", "")) in branches]
|
||||
terminal = {"success", "failure", "cancelled", "skipped"}
|
||||
return any(str(r.get("status", "")).lower() not in terminal
|
||||
for r in runs)
|
||||
|
||||
|
||||
def authority_after_wake(op_type: str = DEFAULT_OP_TYPE, sha: str = "",
|
||||
max_tries: int = 3, gap_secs: int = 4) -> str:
|
||||
"""(sha is EXPLICIT — ambient env never selects the target.)"""
|
||||
"""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 = op_authority_verdict(op_type, sha)
|
||||
if verdict != "pending":
|
||||
return verdict
|
||||
if attempt < max_tries - 1:
|
||||
time.sleep(gap_secs)
|
||||
return verdict
|
||||
|
||||
|
||||
def ci_alive() -> bool:
|
||||
"""LEGACY liveness probe (master-ref deploy runs) — kept for callers
|
||||
predating op specs; run() uses op_ci_alive(op_type, sha)."""
|
||||
return op_ci_alive(DEFAULT_OP_TYPE, os.environ.get("DEPLOY_SHA", ""))
|
||||
|
||||
|
||||
def _set_cognition_state(op: str, state: str) -> None:
|
||||
"""E-PATCH §12: stamp cognitive quiescence on the durable record so a
|
||||
fresh process knows cognition is parked (WAITING_EXTERNAL) vs resumable
|
||||
(RESUMABLE). Best-effort: observability never breaks the wait."""
|
||||
try:
|
||||
rec = ew.operation_wait(op)
|
||||
if rec is None:
|
||||
return
|
||||
wait_file = ew.wr_root() / "waits" / f"{rec.get('wait_id')}.json"
|
||||
if not wait_file.is_file():
|
||||
return
|
||||
data = json.loads(wait_file.read_text())
|
||||
data.setdefault("ext", {})["continuation_state"] = state
|
||||
wait_file.write_text(json.dumps(data, indent=1, sort_keys=True))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def run(*, sha: str, deadline_secs: int,
|
||||
watchdog_secs: int = 300,
|
||||
op_type: str = DEFAULT_OP_TYPE,
|
||||
task_id: str = "",
|
||||
daemon_url: str = ew.DEFAULT_DAEMON_URL) -> int:
|
||||
"""T21.5 §3: ONE LOGICAL AWAIT.
|
||||
|
||||
One logical external wait is one invocation of this function. All
|
||||
nonterminal conditions (event-seen-authority-pending, watchdog expiry
|
||||
with live runs, bounded authority-retry exhausted) re-arm INTERNALLY —
|
||||
control returns to the caller ONLY on a terminal verdict or on real
|
||||
deadline expiry. Watchdogs are failure detectors inside this
|
||||
abstraction, never the scheduler and never completion truth.
|
||||
"""
|
||||
spec = op_spec(op_type) # unknown op_type refused upfront
|
||||
op = f"ci-{sha}"
|
||||
# A-PATCH: the authority subprocess reads DEPLOY_SHA from the env;
|
||||
# python-level run() callers (tool normalization flow) must export it.
|
||||
if sha and not os.environ.get("DEPLOY_SHA"):
|
||||
os.environ["DEPLOY_SHA"] = sha
|
||||
# H-PATCH: resolve short SHAs to full 40-char — _fetch_runs and
|
||||
# deploy_qualify match on the FULL sha; a 12-char prefix would
|
||||
# silently fail equality and park the waiter until deadline
|
||||
# (the 962e351 root cause).
|
||||
if sha and len(sha) < 40:
|
||||
try:
|
||||
full = subprocess.run(
|
||||
["git", "-C", str(_SCRIPT_DIR.parent), "rev-parse", sha],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if full.returncode == 0:
|
||||
sha = full.stdout.strip()
|
||||
os.environ["DEPLOY_SHA"] = sha
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
t0 = time.time()
|
||||
# REAL_ASYNC_BRIDGE: FIFO is replaced by completion-file inotify.
|
||||
# The completion file write is the wake signal; inotify is the
|
||||
# kernel-level zero-latency, zero-CPU wake primitive.
|
||||
# Event replay (T16 §16C live finding): on a serialized runner the
|
||||
# completion event usually arrives BEFORE the waiter 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 {op_type} 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),
|
||||
continuation_state="RESUMABLE", # event already arrived
|
||||
op_type=op_type,
|
||||
authority=spec["authority"])
|
||||
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 = normalize_verdict(authority_after_wake(op_type, sha))
|
||||
if verdict == "pending" and not op_ci_alive(op_type, sha):
|
||||
verdict = normalize_verdict(op_authority_verdict(op_type, sha))
|
||||
mode = ("event_replayed" if verdict != "pending"
|
||||
else "authority_unresolved")
|
||||
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
out = {
|
||||
"sha": sha,
|
||||
"op_type": op_type,
|
||||
"authority": spec["authority"],
|
||||
"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 {op_type} qualification for {sha}",
|
||||
watched_for="Gitea CI completion event (wake; verdict re-read "
|
||||
f"from canonical {spec['authority']} authority)",
|
||||
provider="gitea", event_type="ci_completed", sha=sha,
|
||||
watchdog_secs=min(watchdog_secs, deadline_secs),
|
||||
continuation_state="WAITING_EXTERNAL",
|
||||
op_type=op_type,
|
||||
authority=spec["authority"],
|
||||
task_id=task_id, # §9: bind the operation to the calling task
|
||||
resume_mode="self") # §3: waiter owns its continuation via
|
||||
# completion-file/inotify; the daemon has NO
|
||||
# session-delivery role for this wait
|
||||
registered_at = rec["created_at"]
|
||||
_set_cognition_state(op, "WAITING_EXTERNAL") # cognition parked
|
||||
deadline = time.monotonic() + deadline_secs
|
||||
verdict = "pending"
|
||||
mode = "event"
|
||||
event_at = ""
|
||||
resumed_at = ""
|
||||
rearm_reason = ""
|
||||
rearm_count = 0
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(1, int(deadline - time.monotonic()))
|
||||
|
||||
# REAL_ASYNC_BRIDGE: block on inotify for the completion file.
|
||||
# Zero CPU, zero arbitrary timer. The inotify timeout is a
|
||||
# FAILURE DETECTOR — if no event arrives within the watchdog
|
||||
# window, we check the authority directly (lost-event recovery).
|
||||
# The deadline is containment only.
|
||||
watch_timeout = min(remaining, watchdog_secs)
|
||||
result = cf.wait_for_completion(
|
||||
op, timeout_secs=watch_timeout)
|
||||
|
||||
# REAL_ASYNC_BRIDGE: check durable event log AND wait record
|
||||
# status on EVERY cycle — the daemon may have matched and
|
||||
# transitioned the record via /process-event.
|
||||
wr = ew.operation_wait(op)
|
||||
if wr and wr.get("status") in ("COMPLETED", "FAILED",
|
||||
"READY_TO_RESUME"):
|
||||
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 = normalize_verdict(
|
||||
authority_after_wake(op_type, sha))
|
||||
if verdict != "pending":
|
||||
mode = "event"
|
||||
break
|
||||
prior = ew.find_matching_event("gitea", "ci_completed", op)
|
||||
if prior is not None and prior.get("exit_code") is not None:
|
||||
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 = normalize_verdict(
|
||||
authority_after_wake(op_type, sha))
|
||||
if verdict != "pending":
|
||||
mode = "event"
|
||||
break
|
||||
if result is not None:
|
||||
# completion file exists: event arrived
|
||||
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 = normalize_verdict(
|
||||
authority_after_wake(op_type, sha))
|
||||
if verdict != "pending":
|
||||
mode = "event"
|
||||
break
|
||||
rearm_count += 1
|
||||
rearm_reason = ("completion file seen; authority "
|
||||
"pending — re-subscribing")
|
||||
continue
|
||||
|
||||
# inotify timeout: no event within watchdog window
|
||||
# FAILURE DETECTOR: check authority directly
|
||||
verdict = normalize_verdict(
|
||||
op_authority_verdict(op_type, sha))
|
||||
if verdict != "pending":
|
||||
mode = "watchdog_authority_recovered"
|
||||
break
|
||||
rearm_count += 1
|
||||
rearm_reason = ("watchdog expiry; op pending (not started "
|
||||
"or lagging) — re-subscribing inotify")
|
||||
continue
|
||||
|
||||
# REAL_ASYNC_BRIDGE: no FIFO to close. The completion file is durable
|
||||
# and survives restarts. Lease release is not needed (no lease system
|
||||
# for file-based delivery).
|
||||
|
||||
|
||||
if verdict == "pending":
|
||||
if mode == "event":
|
||||
mode = "deadline_pending"
|
||||
verdict = normalize_verdict(op_authority_verdict(op_type, sha)) # final one-shot truth
|
||||
_set_cognition_state(op, "RESUMABLE" if verdict != "pending"
|
||||
else "RECOVERY_REQUIRED")
|
||||
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
# §2 STATE OWNERSHIP: the WAITER owns the OPERATION domain. On a
|
||||
# terminal authority verdict, stamp the durable record terminal with
|
||||
# the evidence — the daemon's session-delivery machinery must never
|
||||
# (and can no longer) overwrite this truth. Also updates the parked
|
||||
# continuation_state so fresh-process recovery sees reality.
|
||||
try:
|
||||
rec2 = ew.operation_wait(op) or rec
|
||||
if verdict != "pending":
|
||||
rec2["status"] = "COMPLETED" if verdict == "success" else "FAILED"
|
||||
rec2["verdict"] = verdict
|
||||
rec2["resume_evidence"] = {
|
||||
"kind": "authority_verified_terminal",
|
||||
"verdict": verdict,
|
||||
"mode": mode,
|
||||
"authority": spec["authority"],
|
||||
"sha": sha,
|
||||
"op_type": op_type,
|
||||
"verdict_at": verdict_at,
|
||||
}
|
||||
rec2["ext"] = {**rec2.get("ext", {}),
|
||||
"continuation_state": ("RESUMABLE" if verdict != "pending"
|
||||
else "RECOVERY_REQUIRED")}
|
||||
ew._load_waitstore().save(rec2)
|
||||
except Exception as _e: # noqa: BLE001 — record stamping must never corrupt the envelope (the envelope + authority remain the truth)
|
||||
print(f"ci_event_wait: terminal record stamp failed: {_e}",
|
||||
file=sys.stderr)
|
||||
out = {
|
||||
"sha": sha,
|
||||
"op_type": op_type,
|
||||
"authority": spec["authority"],
|
||||
"registered_at": registered_at,
|
||||
"event_received_at": event_at,
|
||||
"controller_resumed_at": resumed_at,
|
||||
"verdict_at": verdict_at,
|
||||
"mode": mode,
|
||||
"rearm_reason": rearm_reason,
|
||||
"internal_rearm_count": rearm_count,
|
||||
"verdict": verdict,
|
||||
"wall_secs": round(time.time() - t0, 3),
|
||||
}
|
||||
print(json.dumps(out))
|
||||
return 0 if verdict == "success" else 1
|
||||
|
||||
KNOWN_FLAGS = ("--sha", "--deadline", "--op-type", "--watchdog", "--task-id")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""§5 CLI CONTRACT: no silent argument loss. Every flag is either
|
||||
explicitly supported (and takes effect) or loudly rejected — an
|
||||
acceptance envelope must reflect the configuration actually used."""
|
||||
sha = os.environ.get("DEPLOY_SHA", "")
|
||||
args = sys.argv[1:]
|
||||
deadline = 900
|
||||
op_type = DEFAULT_OP_TYPE
|
||||
watchdog = 300
|
||||
task_id = ""
|
||||
i = 0
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "--sha":
|
||||
sha = args[i + 1]; i += 2
|
||||
elif a == "--deadline":
|
||||
deadline = int(args[i + 1]); i += 2
|
||||
elif a == "--op-type":
|
||||
op_type = args[i + 1]; i += 2
|
||||
elif a == "--watchdog":
|
||||
watchdog = int(args[i + 1]); i += 2
|
||||
elif a == "--task-id":
|
||||
task_id = args[i + 1]; i += 2
|
||||
elif a.startswith("--"):
|
||||
print(json.dumps({"error": f"unknown flag {a!r}",
|
||||
"known_flags": list(KNOWN_FLAGS),
|
||||
"contract": "no silent argument loss"}))
|
||||
return 2
|
||||
else:
|
||||
print(json.dumps({"error": f"unexpected positional {a!r}",
|
||||
"known_flags": list(KNOWN_FLAGS)}))
|
||||
return 2
|
||||
if not sha:
|
||||
print(json.dumps({"error": "no sha (DEPLOY_SHA or --sha required)"}))
|
||||
return 1
|
||||
# A-PATCH fix: run()'s authority subprocess (deploy_qualify.py) reads
|
||||
# DEPLOY_SHA from the environment — a CLI `--sha` consumer previously
|
||||
# got a silent "pending" verdict forever because the var was never
|
||||
# exported. Export it so the canonical authority can resolve.
|
||||
os.environ["DEPLOY_SHA"] = sha
|
||||
return run(sha=sha, deadline_secs=deadline, op_type=op_type,
|
||||
watchdog_secs=watchdog, task_id=task_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,195 @@
|
||||
"""completion_file.py — REAL_ASYNC_BRIDGE: durable completion-file delivery
|
||||
with inotify wake. Replaces FIFO+lease for process-wait delivery.
|
||||
|
||||
DESIGN:
|
||||
Producer (bridge/daemon/notify):
|
||||
1. Validates operation identity
|
||||
2. Atomically writes a completion file:
|
||||
resumes/<operation_id>.done
|
||||
containing the event/verdict JSON (write to tmp, rename for atomicity)
|
||||
|
||||
Waiter (ci_event_wait / any continuation consumer):
|
||||
1. Checks if the completion file already exists (event-before-waiter)
|
||||
2. If not: registers inotify watch on the resumes/ directory
|
||||
3. Re-checks after watch registration (closes the registration race)
|
||||
4. Blocks in kernel on inotify — zero CPU, zero timer
|
||||
5. On file-created event: reads the file, verifies authority, returns
|
||||
|
||||
The event is a WAKE SIGNAL. Authority still decides terminal truth.
|
||||
The completion file is durable — survives daemon and waiter restarts.
|
||||
|
||||
WHY NOT FIFO:
|
||||
FIFO is one-shot (token consumed), requires lease management, and
|
||||
fails under session-busy guards. A completion file is atomic, durable,
|
||||
and works across restarts. inotify wakes the process with zero CPU
|
||||
cost and zero latency between file creation and wake.
|
||||
|
||||
WHY NOT POLLING:
|
||||
Polling introduces an arbitrary interval. inotify is a kernel callback
|
||||
that fires exactly when the filesystem changes — the latency tracks
|
||||
the real completion event, not a configured interval.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
def _wr_bridge() -> Path:
|
||||
# WR_ROOT = daemon/infra convention; RUNE_WR_ROOT = repo/scripts
|
||||
# convention. Both honored (WR_ROOT wins) so the daemon, the waiter,
|
||||
# and any isolated test root always resolve the SAME directory.
|
||||
root = (os.environ.get("WR_ROOT")
|
||||
or os.environ.get("RUNE_WR_ROOT")
|
||||
or "/mnt/workdrive/wr-bridge")
|
||||
return Path(root)
|
||||
|
||||
def _resumes_dir() -> Path:
|
||||
return _wr_bridge() / "resumes"
|
||||
|
||||
# inotify constants (Linux)
|
||||
IN_CREATE = 0x100
|
||||
IN_CLOSE_WRITE = 0x8
|
||||
IN_MOVED_TO = 0x80
|
||||
|
||||
|
||||
def completion_path(operation_id: str) -> Path:
|
||||
return _resumes_dir() / f"{operation_id}.done"
|
||||
|
||||
|
||||
def write_completion(operation_id: str, event: dict) -> Path:
|
||||
"""Atomically write a durable completion file. The rename(2) ensures
|
||||
the waiter never sees a partial file."""
|
||||
_resumes_dir().mkdir(parents=True, exist_ok=True)
|
||||
target = completion_path(operation_id)
|
||||
tmp = target.parent / f".{target.name}.tmp.{os.getpid()}"
|
||||
tmp.write_text(json.dumps(event, sort_keys=True) + "\n")
|
||||
os.rename(str(tmp), str(target)) # atomic on same filesystem
|
||||
os.chmod(str(target), 0o666) # cross-user bridge
|
||||
return target
|
||||
|
||||
|
||||
def read_completion(operation_id: str) -> dict | None:
|
||||
"""Read a durable completion file. Returns None if absent."""
|
||||
p = completion_path(operation_id)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def completion_exists(operation_id: str) -> bool:
|
||||
return completion_path(operation_id).is_file()
|
||||
|
||||
|
||||
def _inotify_init_and_add_watch(directory: str) -> int:
|
||||
"""Create an inotify instance and add a watch on the directory.
|
||||
Returns the inotify fd."""
|
||||
libc = __import__("ctypes").CDLL(None, use_errno=True)
|
||||
fd = libc.inotify_init()
|
||||
if fd < 0:
|
||||
raise OSError("inotify_init failed")
|
||||
# IN_CREATE | IN_MOVED_TO: catch both direct creation and atomic rename
|
||||
mask = IN_CREATE | IN_MOVED_TO
|
||||
wd = libc.inotify_add_watch(fd, directory.encode(), mask)
|
||||
if wd < 0:
|
||||
os.close(fd)
|
||||
raise OSError(f"inotify_add_watch failed for {directory}")
|
||||
return fd
|
||||
|
||||
|
||||
def _inotify_read(fd: int, timeout_secs: float | None = None) -> list[str]:
|
||||
"""Read pending inotify events. Returns list of filenames that were
|
||||
created/moved-to. Blocks until at least one event or timeout."""
|
||||
poll = select.poll()
|
||||
poll.register(fd, select.POLLIN)
|
||||
events = []
|
||||
buf = os.read(fd, 4096)
|
||||
pos = 0
|
||||
while pos < len(buf):
|
||||
# struct: wd(i32), mask(u32), cookie(u32), name_len(u32)
|
||||
wd, mask, cookie, name_len = struct.unpack_from("iIII", buf, pos)
|
||||
pos += 16
|
||||
name = buf[pos:pos + name_len].rstrip(b"\x00").decode("utf-8", "replace")
|
||||
pos += name_len
|
||||
if mask & (IN_CREATE | IN_MOVED_TO):
|
||||
events.append(name)
|
||||
return events
|
||||
|
||||
|
||||
def wait_for_completion(operation_id: str, *,
|
||||
timeout_secs: float | None = None,
|
||||
poll_callback=None) -> dict | None:
|
||||
"""Block until a completion file exists for this operation.
|
||||
|
||||
Uses inotify for zero-latency, zero-CPU wake. Falls back to
|
||||
polling_callback (if provided) when inotify is unavailable.
|
||||
|
||||
The completion file is a WAKE SIGNAL. The caller must still verify
|
||||
canonical authority after reading it.
|
||||
|
||||
Returns the event dict from the completion file, or None on timeout.
|
||||
"""
|
||||
target = completion_path(operation_id)
|
||||
|
||||
# 1. check-before-watch (event may have arrived before we started)
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
_resumes_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
fd = _inotify_init_and_add_watch(str(_resumes_dir()))
|
||||
except OSError:
|
||||
# inotify unavailable: fall back to polling callback
|
||||
if poll_callback:
|
||||
return poll_callback(operation_id, timeout_secs)
|
||||
return None
|
||||
|
||||
try:
|
||||
# 2. re-check after watch registration (closes the race between
|
||||
# the initial check and the watch being active)
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# 3. block in kernel on inotify — zero CPU, exact latency
|
||||
import select as _select
|
||||
poll = _select.poll()
|
||||
poll.register(fd, _select.POLLIN)
|
||||
deadline = (time.monotonic() + timeout_secs) if timeout_secs else None
|
||||
|
||||
while True:
|
||||
remaining = None
|
||||
if deadline is not None:
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
if remaining <= 0.1:
|
||||
return None # timeout
|
||||
events = poll.poll(remaining * 1000 if remaining else None)
|
||||
if not events:
|
||||
continue # poll timeout, re-check
|
||||
names = _inotify_read(fd)
|
||||
if target.name in names:
|
||||
# 4. file appeared — read it
|
||||
existing = read_completion(operation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# partial/corrupt: keep watching
|
||||
finally:
|
||||
os.close(fd)
|
||||
return None
|
||||
|
||||
|
||||
def clear_completion(operation_id: str) -> None:
|
||||
"""Remove a consumed completion file (idempotent cleanup)."""
|
||||
p = completion_path(operation_id)
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -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)
|
||||
+143
-21
@@ -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,34 +30,146 @@ 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,
|
||||
max_pages: int = 4) -> list[dict] | None:
|
||||
"""H-PATCH: paginate the runs list. The repo's scheduled merge-robot
|
||||
runs flood the first page (~50 slots exhaust within hours), pushing
|
||||
older-but-relevant SHAs to later pages — the cause of the observed
|
||||
authority flapping (terminal commit read as pending). Paginate until
|
||||
the sha is found or max_pages exhausted."""
|
||||
out: list[dict] = []
|
||||
for page in range(1, max_pages + 1):
|
||||
data = _get_json(
|
||||
f"{server}/api/v1/repos/{repo}/actions/runs?limit=50&page={page}",
|
||||
token)
|
||||
if data is None:
|
||||
return None if page == 1 else (out or None)
|
||||
runs = data.get("workflow_runs", data if isinstance(data, list) else [])
|
||||
out.extend(r for r in runs if str(r.get("head_sha", "")) == sha)
|
||||
if len(runs) < 50:
|
||||
break # exhausted
|
||||
return out
|
||||
|
||||
|
||||
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("/")
|
||||
repo = os.environ.get("GITEA_REPO", "")
|
||||
sha = os.environ.get("DEPLOY_SHA", "")
|
||||
# T21.4 fix: fall back to the canonical defaults when the caller did
|
||||
# not export GITEA_SERVER/GITEA_REPO (CI always exports them; local
|
||||
# and first-class-tool consumers previously got a silent "pending"
|
||||
# verdict — a wrong answer, not a fail-closed one).
|
||||
# H-PATCH H4: the target sha arrives EXPLICITLY (--sha) from callers
|
||||
# that hold structured operation state. DEPLOY_SHA env remains only
|
||||
# for standalone/workflow backward compat — the canonical path never
|
||||
# depends on ambient env for operation identity.
|
||||
ap = __import__("argparse").ArgumentParser()
|
||||
ap.add_argument("--sha", dest="sha_arg", default=None)
|
||||
args, _unknown = ap.parse_known_args()
|
||||
server = (os.environ.get("GITEA_SERVER", "")
|
||||
or gitea_auth.DEFAULT_SERVER).rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO", "") or gitea_auth.DEFAULT_REPO
|
||||
sha = args.sha_arg or os.environ.get("DEPLOY_SHA", "")
|
||||
token = gitea_auth.get_token() if gitea_auth.has_token() else ""
|
||||
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__":
|
||||
|
||||
@@ -86,9 +86,12 @@ def main() -> int:
|
||||
if live_state == "open" and rec["tracker_state"] == "closed" and rec["reconciliation_state"] in TERMINAL_STATES:
|
||||
drift.append(f"#{num}: live state open but artifact says closed/{rec['reconciliation_state']}")
|
||||
live_bug = "bug" in [l["name"] for l in li.get("labels", [])]
|
||||
if live_bug and not rec.get("live_defect"):
|
||||
# Same open-only exemption as the artifact-records block above: a
|
||||
# closed issue's labels are historical record; FIXED_VERIFIED issues
|
||||
# legitimately keep the bug label after closure.
|
||||
if live_bug and rec.get("tracker_state") == "open" and not rec.get("live_defect"):
|
||||
drift.append(f"#{num}: live bug label present but artifact records no live defect")
|
||||
if not live_bug and rec.get("live_defect"):
|
||||
if not live_bug and rec.get("live_defect") and rec.get("tracker_state") == "open":
|
||||
drift.append(f"#{num}: artifact records live defect but live bug label absent")
|
||||
|
||||
# Live defects in the artifact must all be OPEN in the tracker
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install_mimo_hook.py — T21.2 §21.2D: canonical MiMo hook installer.
|
||||
|
||||
Canonical source: config/mimo-hooks/event-wait-guard.ts
|
||||
Target: ~/.mimocode/hooks/event-wait-guard.ts
|
||||
Verifies hash after install. Detects drift in both directions."""
|
||||
import hashlib
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CANONICAL = Path(__file__).resolve().parent.parent / "config" / "mimo-hooks" / "event-wait-guard.ts"
|
||||
TARGET = Path.home() / ".mimocode" / "hooks" / "event-wait-guard.ts"
|
||||
|
||||
|
||||
def sha256(p: Path) -> str:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CANONICAL.is_file():
|
||||
print(f"canonical source missing: {CANONICAL}")
|
||||
return 1
|
||||
canon_hash = sha256(CANONICAL)
|
||||
if TARGET.is_file():
|
||||
target_hash = sha256(TARGET)
|
||||
if target_hash == canon_hash:
|
||||
print(f"in-sync: {target_hash[:12]}")
|
||||
return 0
|
||||
print(f"DRIFT: canonical={canon_hash[:12]} installed={target_hash[:12]}")
|
||||
else:
|
||||
print(f"hook missing at {TARGET}")
|
||||
TARGET.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(CANONICAL, TARGET)
|
||||
installed_hash = sha256(TARGET)
|
||||
if installed_hash != canon_hash:
|
||||
print(f"INSTALL FAILED: hash mismatch after copy")
|
||||
return 1
|
||||
print(f"installed: {installed_hash[:12]}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install_wr_daemon.py — REAL_ASYNC_BRIDGE §6: daemon governance.
|
||||
|
||||
Canonical source lives IN THIS REPO at bridge/daemon/. The active install
|
||||
is ~/.local/share/mimo-wait-resume/. This script is the ONLY supported
|
||||
way to move code between them:
|
||||
|
||||
- copy canonical source → install dir (atomic, per file)
|
||||
- verify sha256 convergence (fail loud on divergence)
|
||||
- optionally restart the daemon via scripts/start_wr_daemon.py
|
||||
|
||||
Never hand-edit the installed copy: edit the repo source, install, restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CANONICAL = REPO / "bridge" / "daemon"
|
||||
INSTALL = Path.home() / ".local/share/mimo-wait-resume"
|
||||
FILES = ("daemon.py", "waitstore.py", "verify.py", "completion_file.py")
|
||||
|
||||
|
||||
def sha256(p: Path) -> str:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--check", action="store_true", help="verify convergence only; no writes")
|
||||
ap.add_argument("--restart", action="store_true", help="restart the daemon after install")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not CANONICAL.is_dir():
|
||||
print(f"canonical source missing: {CANONICAL}")
|
||||
return 1
|
||||
INSTALL.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
diverged = []
|
||||
for name in FILES:
|
||||
src, dst = CANONICAL / name, INSTALL / name
|
||||
if args.check:
|
||||
if not dst.is_file() or sha256(src) != sha256(dst):
|
||||
diverged.append(name)
|
||||
continue
|
||||
if not dst.is_file() or sha256(src) != sha256(dst):
|
||||
tmp = dst.with_suffix(f".tmp.{name}")
|
||||
shutil.copy2(src, tmp)
|
||||
tmp.replace(dst) # atomic
|
||||
print(f"installed: {name} {sha256(src)[:12]}")
|
||||
else:
|
||||
print(f"unchanged: {name} {sha256(src)[:12]}")
|
||||
|
||||
if args.check:
|
||||
if diverged:
|
||||
print(f"DIVERGED: {', '.join(diverged)} — run install_wr_daemon.py")
|
||||
return 1
|
||||
print("CONVERGED: installed copy == canonical repo source")
|
||||
return 0
|
||||
|
||||
# post-install convergence proof
|
||||
bad = [n for n in FILES if sha256(CANONICAL / n) != sha256(INSTALL / n)]
|
||||
if bad:
|
||||
print(f"INSTALL FAILED convergence: {bad}")
|
||||
return 1
|
||||
print("CONVERGED: installed copy == canonical repo source")
|
||||
|
||||
if args.restart:
|
||||
rc = subprocess.call([sys.executable, str(REPO / "scripts" / "start_wr_daemon.py")])
|
||||
if rc != 0:
|
||||
print("restart failed")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/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.
|
||||
|
||||
REAL_ASYNC_BRIDGE (R3): the completion event is ALSO written directly as
|
||||
a durable completion file (resumes/<operation_id>.done). Delivery no
|
||||
longer depends on the daemon being alive: the waiter blocks on inotify
|
||||
for the file itself (kernel wake, zero latency, zero CPU), and the
|
||||
durable file survives daemon AND waiter restarts (R9). The write is
|
||||
best-effort on the same terms as the POST — never fails 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(),
|
||||
}
|
||||
# R3 durable delivery: write the completion file FIRST — it works even
|
||||
# if the bridge daemon is down, and the atomic rename means any waiter
|
||||
# (inotify-watching or starting later) sees a complete file or none.
|
||||
try:
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import completion_file as cf # noqa: E402 — sibling module
|
||||
written = cf.write_completion(payload["operation_id"], payload)
|
||||
print(f"notify-event-bridge: durable completion file {written}",
|
||||
flush=True)
|
||||
except Exception as exc: # noqa: BLE001 — delivery is best-effort; watchdog recovery covers a lost completion file (§16D contract)
|
||||
print(f"notify-event-bridge: completion-file write failed ({exc}); "
|
||||
f"bridge POST and watchdog recovery still cover delivery",
|
||||
flush=True)
|
||||
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 = [
|
||||
|
||||
@@ -38,11 +38,17 @@ def build_context(issue: dict, define_contract: dict) -> dict:
|
||||
# 3. Find matching test files
|
||||
matched_tests = _find_tests(keywords)
|
||||
|
||||
# 4. T21.5 §10: Memory internal-experience port — ONE port of call,
|
||||
# bounded, zero-result = normal (fast path: [] → next step directly).
|
||||
from research_memory_port import memory_port_of_call
|
||||
memory_candidates = memory_port_of_call(issue, keywords)
|
||||
|
||||
return {
|
||||
"keywords": keywords,
|
||||
"umbrella_matches": matched_modules,
|
||||
"candidate_files": matched_files[:15], # bounded
|
||||
"candidate_tests": matched_tests[:10], # bounded
|
||||
"memory_candidates": memory_candidates, # evidence, never authority
|
||||
"define_contract": define_contract,
|
||||
"mimo_needed": _needs_mimo(matched_files, matched_tests),
|
||||
}
|
||||
@@ -77,9 +83,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": {}}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""research_memory_port.py — T21.5 §10-12: the future-facing Memory port
|
||||
inside the EXISTING INWARD research stage.
|
||||
|
||||
BOUNDARIES (G9/G10/G11):
|
||||
- This is a PORT, not a memory system. No storage is created here; the
|
||||
implementation delegates to a provider callable that future Memory
|
||||
will supply. Tests inject doubles; INWARD never imports Memory.
|
||||
- Zero result is a NORMAL SUCCESSFUL result: the caller continues the
|
||||
normal INWARD flow immediately (no extra model call, no ceremony).
|
||||
- Returned material is EVIDENCE/CANDIDATES only — never an instruction
|
||||
authority. APPLICABILITY (existing stage) governs whether a candidate
|
||||
applies; SYNTHESIS receives only distilled, applicable consequences.
|
||||
- Teachable-moment seam: durable construction evidence can mark an
|
||||
episode as a CANDIDATE for future learning ("may be worth learning",
|
||||
never "always apply"). Substrate/Memory (future) harvest candidates;
|
||||
today the seam only records them via the same provider contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
# The provider contract: ONE callable, independently replaceable/mockable.
|
||||
# Signature: query(query_text, keywords, *, limit) -> list[MemoryCandidate]
|
||||
# MemoryCandidate (dict): {source, kind, summary, relevance, episode_ref}
|
||||
# kind ∈ {construction_episode, teachable_moment_candidate,
|
||||
# prior_failure, prior_success}
|
||||
# Future Memory supplies the real implementation; nothing else changes.
|
||||
|
||||
MemoryProvider = Callable[..., list[dict]]
|
||||
|
||||
_DEFAULT_PROVIDER: MemoryProvider | None = None
|
||||
|
||||
|
||||
def set_memory_provider(provider: MemoryProvider | None) -> None:
|
||||
"""Bind the Memory-side implementation (production or test double).
|
||||
None disables the port — INWARD continues without it (fast path)."""
|
||||
global _DEFAULT_PROVIDER
|
||||
_DEFAULT_PROVIDER = provider
|
||||
|
||||
|
||||
def _provider() -> MemoryProvider | None:
|
||||
global _DEFAULT_PROVIDER
|
||||
if _DEFAULT_PROVIDER is not None:
|
||||
return _DEFAULT_PROVIDER
|
||||
env = os.environ.get("RUNE_MEMORY_PORT_PROVIDER")
|
||||
if env:
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("mprov", env)
|
||||
if spec is not None and spec.loader is not None:
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
provider = getattr(mod, "query", None)
|
||||
if provider is not None:
|
||||
_DEFAULT_PROVIDER = provider
|
||||
return _DEFAULT_PROVIDER
|
||||
|
||||
|
||||
MAX_CANDIDATES = 5
|
||||
MIN_RELEVANCE = 0.3
|
||||
|
||||
|
||||
def memory_port_of_call(issue: dict, keywords: list[str],
|
||||
*, provider: MemoryProvider | None = None) -> list[dict]:
|
||||
"""The ONE internal port of call inside INWARD. Bounded retrieval of
|
||||
internal evidence (construction episodes, teachable-moment
|
||||
candidates, prior failures/successes).
|
||||
|
||||
ZERO-RESULT FAST PATH (§11): empty/thin results return [] and the
|
||||
caller continues the next normal INWARD step directly. No extra
|
||||
model call, no fallback reasoning, no synthetic summary, no delay.
|
||||
"""
|
||||
prov = provider or _provider()
|
||||
if prov is None:
|
||||
return [] # port unbound: normal INWARD continues, zero overhead
|
||||
try:
|
||||
query_text = " ".join([
|
||||
str(issue.get("title", "")),
|
||||
str(issue.get("body", ""))[:400],
|
||||
]).strip()
|
||||
raw = prov(query_text, keywords, limit=MAX_CANDIDATES) or []
|
||||
except Exception: # noqa: BLE001 — port failure must never break INWARD
|
||||
return []
|
||||
candidates = [c for c in raw
|
||||
if float(c.get("relevance", 0.0)) >= MIN_RELEVANCE]
|
||||
return candidates[:MAX_CANDIDATES]
|
||||
|
||||
|
||||
def applicability_filter(candidates: list[dict],
|
||||
context_keywords: list[str]) -> tuple[list[dict],
|
||||
list[dict]]:
|
||||
"""Existing APPLICABILITY governs transfer: candidates relevant to
|
||||
the CURRENT context pass; irrelevant ones are rejected WITHOUT
|
||||
contaminating downstream SYNTHESIS. Returns (applicable, rejected)."""
|
||||
applicable, rejected = [], []
|
||||
for c in candidates:
|
||||
text = (str(c.get("summary", "")) + " " +
|
||||
" ".join(str(k) for k in c.get("keywords", []))).lower()
|
||||
overlap = sum(1 for k in context_keywords if k and k.lower() in text)
|
||||
if overlap > 0 or c.get("explicitly_applicable") is True:
|
||||
applicable.append(c)
|
||||
else:
|
||||
rejected.append(c)
|
||||
return applicable, rejected
|
||||
|
||||
|
||||
# ── §12: teachable-moment future seam ──────────────────────────────────────
|
||||
|
||||
CANDIDATE_TRIGGERS = (
|
||||
"completed_release", "capability_completed", "failure_recovered",
|
||||
"rollback_executed", "user_correction", "prediction_observation_mismatch",
|
||||
"procedure_improved",
|
||||
)
|
||||
|
||||
|
||||
def mark_teachable_moment_candidate(
|
||||
episode_ref: str, *, trigger: str, summary: str,
|
||||
emit: Callable[[dict], Any] | None = None) -> dict | None:
|
||||
"""Record durable evidence that an episode MAY be worth future
|
||||
learning — a candidate, never a rule. Uses the existing evidence/
|
||||
event mechanism via `emit` (the caller's channel: journal, event
|
||||
bridge, ledger history). No new canonical store is created here;
|
||||
future Substrate/Memory harvests candidates through the port."""
|
||||
if trigger not in CANDIDATE_TRIGGERS:
|
||||
raise ValueError(
|
||||
f"unknown trigger {trigger!r} — must be one of "
|
||||
f"{CANDIDATE_TRIGGERS}")
|
||||
record = {
|
||||
"candidate": "teachable_moment",
|
||||
"episode_ref": episode_ref,
|
||||
"trigger": trigger,
|
||||
"summary": summary[:200],
|
||||
"status": "CANDIDATE", # never authority, never auto-applied
|
||||
"note": "may be worth future learning; APPLICABILITY governs "
|
||||
"any future transfer",
|
||||
}
|
||||
if emit is not None:
|
||||
emit(record)
|
||||
return record
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""rollback_generation.py — T21.4 §21.4F: the governed rollback API.
|
||||
|
||||
rollback_generation(target_generation, expected_current, reason)
|
||||
|
||||
Prior tranches proved rollback by recreating deployment primitives by hand
|
||||
(ln -sfn against the live deploy root). That is operator work, not governed
|
||||
machinery — it bypasses lock, eligibility, health, and history. This module
|
||||
is the single canonical rollback operation, built ON the proven deploy
|
||||
machinery (deploy_promotion's lock + atomic swap + build-identity authority),
|
||||
not beside it.
|
||||
|
||||
The promotion authority (deploy_promotion.py) is deliberately MONOTONIC: an
|
||||
ancestor promotion is refused as STALE_SUPERSEDED. Regression is therefore
|
||||
impossible through promotion — and legitimate only through THIS operation,
|
||||
which owns its own obligations:
|
||||
|
||||
1. deployment lock — the SAME .promotion.lock as promote (serialize)
|
||||
2. current verification — build-identity readback must equal expected_current
|
||||
3. target existence — generation dir present, build identity readable
|
||||
4. eligibility — target commit is ancestor-or-equal of current
|
||||
(regression only; forward = promotion's job;
|
||||
divergent/unknown refused)
|
||||
5. atomic switch — rename(2) symlink swap, never absent/half-updated
|
||||
6. runtime handoff — Rune executes from current/src at process start;
|
||||
handoff = identity a fresh launch would run
|
||||
7. runtime verification — fresh-launch identity must equal target
|
||||
8. health qualification — import smoke against target generation src
|
||||
9. durable history — JSONL rollback events, appended inside the lock
|
||||
10. failure recovery — health failure restores prior current (where safe)
|
||||
11. coherent last-good — last-good := the generation that WAS current
|
||||
(same invariant promotion maintains)
|
||||
|
||||
Truthful outcomes:
|
||||
ROLLED_BACK target active, health green, history recorded
|
||||
NOOP_ALREADY_CURRENT target already the deployed generation
|
||||
ROLLED_BACK_REVERTED switched, health red, prior current restored
|
||||
REFUSED_UNKNOWN_CURRENT deployed identity unreadable (fail closed)
|
||||
REFUSED_EXPECTED_MISMATCH current != expected_current (stale caller)
|
||||
REFUSED_TARGET_MISSING target generation dir absent
|
||||
REFUSED_TARGET_UNREADABLE target build identity unreadable
|
||||
REFUSED_NOT_ELIGIBLE divergent, newer, or non-ancestor target
|
||||
REFUSED_NO_REASON empty reason (accountability is mandatory)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(_SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import deploy_promotion # proven machinery: lock path, ancestry, swaps
|
||||
|
||||
DEPLOY_ROOT = deploy_promotion.DEPLOY_ROOT
|
||||
HARNESS_ROOT = _SCRIPT_DIR.parent
|
||||
HISTORY_PATH = HARNESS_ROOT / "store" / "scaffold" / "rollback_history.jsonl"
|
||||
|
||||
ROLLED_BACK = "ROLLED_BACK"
|
||||
NOOP = "NOOP_ALREADY_CURRENT"
|
||||
ROLLED_BACK_REVERTED = "ROLLED_BACK_REVERTED"
|
||||
REFUSED_UNKNOWN_CURRENT = "REFUSED_UNKNOWN_CURRENT"
|
||||
REFUSED_EXPECTED_MISMATCH = "REFUSED_EXPECTED_MISMATCH"
|
||||
REFUSED_TARGET_MISSING = "REFUSED_TARGET_MISSING"
|
||||
REFUSED_TARGET_UNREADABLE = "REFUSED_TARGET_UNREADABLE"
|
||||
REFUSED_NOT_ELIGIBLE = "REFUSED_NOT_ELIGIBLE"
|
||||
REFUSED_NO_REASON = "REFUSED_NO_REASON"
|
||||
|
||||
|
||||
def _identity(deploy_root: Path, generation: str) -> str:
|
||||
"""Authoritative commit for a generation dir (build identity, not name)."""
|
||||
gen_dir = deploy_root / generation
|
||||
identity = gen_dir / "src" / "rune" / "_build_identity.json"
|
||||
try:
|
||||
data = json.loads(identity.read_text(encoding="utf-8"))
|
||||
return str(data.get("commit", ""))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def _health(deploy_root: Path, generation: str, timeout: int = 60) -> tuple[bool, str]:
|
||||
"""Health qualification for a generation: build identity readable AND
|
||||
`import rune` succeeds from that generation's src tree."""
|
||||
gen_dir = deploy_root / generation
|
||||
commit = _identity(deploy_root, generation)
|
||||
if not commit:
|
||||
return False, "build identity unreadable"
|
||||
src = gen_dir / "src"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", "import rune; import sys; "
|
||||
"sys.stdout.write(getattr(rune, '__version__', 'ok'))"],
|
||||
env={"PYTHONPATH": str(src), "PATH": "/usr/bin:/bin",
|
||||
"HOME": str(gen_dir)},
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(gen_dir),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"import smoke timed out after {timeout}s"
|
||||
if proc.returncode != 0:
|
||||
return False, f"import smoke failed: {(proc.stderr or '')[-200:]}"
|
||||
return True, f"identity={commit[:12]} import_smoke=ok ({proc.stdout.strip()[:40]})"
|
||||
|
||||
|
||||
def _append_history(record: dict, history_path: Path = HISTORY_PATH) -> None:
|
||||
history_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(history_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _swap(deploy_root: Path, name: str, target: Path) -> None:
|
||||
deploy_promotion._atomic_symlink_target(deploy_root, name, target)
|
||||
|
||||
|
||||
def rollback_generation(
|
||||
target_generation: str,
|
||||
expected_current: str,
|
||||
reason: str,
|
||||
*,
|
||||
deploy_root: Path = DEPLOY_ROOT,
|
||||
repo: Path = HARNESS_ROOT,
|
||||
history_path: Path = HISTORY_PATH,
|
||||
) -> tuple[str, dict]:
|
||||
"""The governed rollback operation. Returns (outcome, detail).
|
||||
|
||||
Refusals happen BEFORE any mutation of current/last-good. The lock is
|
||||
shared with promote() so rollback and promotion can never interleave.
|
||||
"""
|
||||
if not reason or not reason.strip():
|
||||
return REFUSED_NO_REASON, {"error": "rollback requires a reason"}
|
||||
|
||||
lock_path = deploy_root / ".promotion.lock"
|
||||
# The lock file may be owned by the CI runner (promotion side opens it
|
||||
# for writing). flock needs only an open fd, never write permission, so
|
||||
# open read-only when write is denied — same file, same fence.
|
||||
try:
|
||||
lock_fd = open(lock_path, "a+")
|
||||
except PermissionError:
|
||||
lock_fd = open(lock_path) if lock_path.exists() else \
|
||||
open(lock_path, "a+")
|
||||
with lock_fd:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
current = deploy_root / "current"
|
||||
last_good = deploy_root / "last-good"
|
||||
detail: dict = {"target": target_generation,
|
||||
"expected_current": expected_current,
|
||||
"reason": reason[:200],
|
||||
"at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())}
|
||||
|
||||
# 2. current verification — identity authority, never the name
|
||||
current_generation = ""
|
||||
try:
|
||||
current_generation = Path(os_readlink(current)).name
|
||||
except OSError:
|
||||
pass
|
||||
deployed_commit = deploy_promotion.read_deployed_commit(deploy_root)
|
||||
if not deployed_commit:
|
||||
return REFUSED_UNKNOWN_CURRENT, {
|
||||
"error": "deployed commit unreadable — fail closed",
|
||||
**detail}
|
||||
detail["current_generation"] = current_generation
|
||||
detail["current_commit"] = deployed_commit
|
||||
if expected_current and expected_current != deployed_commit:
|
||||
detail["error"] = (
|
||||
f"current is {deployed_commit[:12]}, "
|
||||
f"caller expected {expected_current[:12]} — refused "
|
||||
"before mutation")
|
||||
return REFUSED_EXPECTED_MISMATCH, detail
|
||||
|
||||
# 3. target existence + identity
|
||||
target_dir = deploy_root / target_generation
|
||||
if not target_dir.is_dir():
|
||||
detail["error"] = f"generation dir {target_generation} absent"
|
||||
return REFUSED_TARGET_MISSING, detail
|
||||
target_commit = _identity(deploy_root, target_generation)
|
||||
if not target_commit:
|
||||
detail["error"] = (
|
||||
f"generation {target_generation} build identity unreadable")
|
||||
return REFUSED_TARGET_UNREADABLE, detail
|
||||
detail["target_commit"] = target_commit
|
||||
|
||||
if target_commit == deployed_commit:
|
||||
detail["note"] = "target already the deployed generation"
|
||||
return NOOP, detail
|
||||
|
||||
# 4. eligibility — ancestor-or-equal only (regression)
|
||||
rel = deploy_promotion.ancestry_of(target_commit, deployed_commit,
|
||||
repo)
|
||||
if rel != "ancestor":
|
||||
detail["error"] = (
|
||||
f"target commit ancestry={rel} — only ancestors are "
|
||||
"rollback-eligible (forward movement belongs to "
|
||||
"deploy_promotion)")
|
||||
return REFUSED_NOT_ELIGIBLE, detail
|
||||
|
||||
# 5. atomic switch (inside lock), last-good := previous current
|
||||
if last_good.is_symlink():
|
||||
last_good.unlink()
|
||||
_swap(deploy_root, "last-good", Path(os_readlink(current)))
|
||||
_swap(deploy_root, "current", target_dir)
|
||||
detail["switched_to"] = target_generation
|
||||
|
||||
# 7+8. runtime verification + health qualification
|
||||
post_commit = deploy_promotion.read_deployed_commit(deploy_root)
|
||||
health_ok, health_detail = _health(deploy_root, target_generation)
|
||||
detail["post_switch_identity"] = post_commit
|
||||
detail["health"] = health_detail
|
||||
|
||||
record = {"at": detail["at"], "operation": "rollback_generation",
|
||||
**detail}
|
||||
if post_commit != target_commit or not health_ok:
|
||||
# 10. failure recovery — restore prior current (safe: we
|
||||
# hold the lock and the prior generation is intact)
|
||||
prev = Path(os_readlink(last_good)) if last_good.is_symlink() \
|
||||
else None
|
||||
if prev is not None:
|
||||
_swap(deploy_root, "current", prev)
|
||||
detail["reverted_to"] = (Path(str(os_readlink(current))).name
|
||||
if current.is_symlink() else "?")
|
||||
detail["outcome"] = ROLLED_BACK_REVERTED
|
||||
record["outcome"] = ROLLED_BACK_REVERTED
|
||||
_append_history(record, history_path)
|
||||
return ROLLED_BACK_REVERTED, detail
|
||||
|
||||
record["outcome"] = ROLLED_BACK
|
||||
_append_history(record, history_path)
|
||||
detail["outcome"] = ROLLED_BACK
|
||||
return ROLLED_BACK, detail
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def os_readlink(p: Path) -> str:
|
||||
return str(Path(str(p)).readlink()) if hasattr(Path, "readlink") \
|
||||
else str(Path(str(p)).readlink())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("target_generation")
|
||||
ap.add_argument("--expected-current", required=True,
|
||||
help="commit the caller believes is currently deployed")
|
||||
ap.add_argument("--reason", required=True)
|
||||
args = ap.parse_args()
|
||||
outcome, detail = rollback_generation(args.target_generation,
|
||||
args.expected_current, args.reason)
|
||||
print(json.dumps({"outcome": outcome, **detail}, indent=2, sort_keys=True))
|
||||
return 0 if outcome in (ROLLED_BACK, NOOP) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -2,17 +2,17 @@
|
||||
"""scaffold_projection.py — Scaffolding V1 Tranche 1 (projection, not authority).
|
||||
|
||||
Answers, per component/module:
|
||||
what subsystem owns X? → config/umbrellas.json (existing authority)
|
||||
what does X depend on? → AST import graph (actual) + umbrella depends_on
|
||||
who consumes X? → reverse import edges (actual)
|
||||
what changes if X changes? → mirrors + dependent umbrellas + import consumers
|
||||
what proof obligations arise? → unit test, canary, henxel coverage, umbrella gates
|
||||
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)
|
||||
|
||||
PROJECTION DISCIPLINE: every field is DERIVED from an existing authority
|
||||
(umbrella manifest, canary_native registry, filesystem, AST). This script
|
||||
introduces no new state — if it disagrees with the authorities, fix the
|
||||
authority, never this projection.
|
||||
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
|
||||
@@ -27,11 +27,17 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / "src" / "rune"
|
||||
TESTS = ROOT / "tests"
|
||||
UMBRELLAS = ROOT / "config" / "umbrellas.json"
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
from rune import spine # noqa: E402 — Spine IS the structural authority
|
||||
|
||||
|
||||
def load_umbrellas() -> dict:
|
||||
return json.loads(UMBRELLAS.read_text())["umbrellas"]
|
||||
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]:
|
||||
@@ -62,23 +68,23 @@ 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 umbrella_for(module: str, umbrellas: dict) -> str:
|
||||
for name, u in umbrellas.items():
|
||||
if module in u.get("modules", []):
|
||||
return name
|
||||
return ""
|
||||
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_umbrellas(umbrella: str, umbrellas: dict) -> list[str]:
|
||||
return [name for name, u in umbrellas.items()
|
||||
if umbrella in u.get("depends_on", [])]
|
||||
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(module: str, umbrellas: dict) -> list[str]:
|
||||
for u in umbrellas.values():
|
||||
if module in u.get("modules", []):
|
||||
return list(u.get("mirrors", []))
|
||||
return []
|
||||
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:
|
||||
@@ -92,9 +98,10 @@ def canary_registered(module: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def obligations(module: str, graph: dict[str, set[str]], umbrellas: dict) -> list[dict]:
|
||||
"""Proof obligations for changing this module, with evidence state."""
|
||||
umb = umbrella_for(module, umbrellas)
|
||||
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(),
|
||||
@@ -112,38 +119,45 @@ def obligations(module: str, graph: dict[str, set[str]], umbrellas: dict) -> lis
|
||||
"evidence": "run: henxels check --all"},
|
||||
]
|
||||
if umb:
|
||||
deps = dependent_umbrellas(umb, umbrellas)
|
||||
deps = dependent_subsystems(doc, umb)
|
||||
if deps:
|
||||
obs.append({"obligation": "dependent_canaries", "satisfied": None,
|
||||
"evidence": f"umbrellas depending on '{umb}': {', '.join(deps)} — their canaries gate the change"})
|
||||
mir = mirrors_for(module, umbrellas)
|
||||
"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, graph: dict[str, set[str]], umbrellas: dict) -> dict:
|
||||
deps = {d for d in graph.get(module, set())
|
||||
if (SRC / f"{d}.py").is_file() and d != module}
|
||||
return {
|
||||
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": umbrella_for(module, umbrellas),
|
||||
"dependencies": sorted(deps),
|
||||
"consumers": sorted(consumers_of(module, graph)),
|
||||
"mirrors": mirrors_for(module, umbrellas),
|
||||
"obligations": obligations(module, graph, umbrellas),
|
||||
"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:
|
||||
umbrellas = load_umbrellas()
|
||||
graph = import_graph()
|
||||
card = component_card(module, graph, umbrellas)
|
||||
doc = load_spine()
|
||||
card = component_card(module, doc)
|
||||
umb = card["subsystem"]
|
||||
card["expected_impact"] = {
|
||||
"direct_consumers": card["consumers"],
|
||||
"umbrella_dependents": dependent_umbrellas(umb, umbrellas) if umb else [],
|
||||
"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"]
|
||||
@@ -161,23 +175,23 @@ def main() -> int:
|
||||
if cmd in ("component", "impact", "obligations") and len(args) < 2:
|
||||
print(f"usage: {cmd} <module>", file=sys.stderr)
|
||||
return 2
|
||||
umbrellas = load_umbrellas()
|
||||
graph = import_graph()
|
||||
doc = load_spine()
|
||||
if cmd == "impact":
|
||||
print(json.dumps(impact(args[1]), indent=1))
|
||||
elif cmd == "component":
|
||||
print(json.dumps(component_card(args[1], graph, umbrellas), indent=1))
|
||||
print(json.dumps(component_card(args[1], doc), indent=1))
|
||||
elif cmd == "obligations":
|
||||
card = component_card(args[1], graph, umbrellas)
|
||||
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, graph, umbrellas)
|
||||
for p in sorted(SRC.glob("*.py"))}
|
||||
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": ["config/umbrellas.json", "src/rune/canary_native.py",
|
||||
"AST import graph", "tests/ filesystem"],
|
||||
"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)")
|
||||
|
||||
@@ -18,6 +18,13 @@ from __future__ import annotations
|
||||
import re
|
||||
import sys
|
||||
|
||||
# T20 §20B: push-convergence synthesizes PR titles as "<kind>: converged
|
||||
# release (push-to-master bump)" where kind comes from
|
||||
# version_convergence.highest_warranted_bump. The title text must carry
|
||||
# that decision — "minor:" was re-classified as patch here, downgrading
|
||||
# every feature tranche (observed: feat ranges bumped 0.66.77-83 not 0.67.0).
|
||||
MINOR_PREFIX_RE = re.compile(r"^\s*minor\b", re.IGNORECASE)
|
||||
|
||||
BREAKING_RE = re.compile(r"^\s*\w+(\([^)]*\))?!:")
|
||||
FEAT_RE = re.compile(r"^\s*feat(\([^)]*\))?")
|
||||
|
||||
@@ -25,6 +32,8 @@ FEAT_RE = re.compile(r"^\s*feat(\([^)]*\))?")
|
||||
def next_version(version: str, title: str) -> str:
|
||||
if BREAKING_RE.match(title) or "BREAKING CHANGE" in title:
|
||||
kind = "major"
|
||||
elif MINOR_PREFIX_RE.match(title):
|
||||
kind = "minor" # synthesized convergence title carries the kind
|
||||
elif FEAT_RE.match(title):
|
||||
kind = "minor"
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""start_wr_daemon.py — T21.5 §7: THE canonical daemon restart launcher.
|
||||
|
||||
T21.4 live finding: `python3 daemon.py` defaults WR_ROOT to the daemon's
|
||||
install dir, so a restart silently opened a second event store. This
|
||||
launcher resolves the root ONCE through scripts/wr_root_resolver.py (the
|
||||
single authority) and passes it explicitly — nobody has to remember to
|
||||
export WR_ROOT.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import wr_root_resolver as resolver # noqa: E402
|
||||
|
||||
DAEMON_CODE_CANDIDATES = (
|
||||
"/home/nick/.local/share/mimo-wait-resume/daemon.py",
|
||||
)
|
||||
|
||||
|
||||
def find_daemon_code() -> Path:
|
||||
for cand in DAEMON_CODE_CANDIDATES:
|
||||
p = Path(cand)
|
||||
if p.is_file():
|
||||
return p
|
||||
raise RuntimeError(f"daemon.py not found in {DAEMON_CODE_CANDIDATES}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = resolver.resolve_root()
|
||||
daemon = find_daemon_code()
|
||||
env = dict(os.environ,
|
||||
WR_ROOT=str(root), # daemon data root
|
||||
RUNE_WR_ROOT=str(root), # adapter-side authority
|
||||
RUNE_WR_CODE=str(daemon.parent))
|
||||
port = sys.argv[1] if len(sys.argv) > 1 else "8791"
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(daemon), port],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
# bounded startup probe (process-local, not an external wait)
|
||||
for _ in range(20):
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/health", timeout=1) as r:
|
||||
if r.status == 200:
|
||||
print(f"wr daemon up: pid={proc.pid} root={root} "
|
||||
f"port={port}")
|
||||
return 0
|
||||
except OSError:
|
||||
time.sleep(0.25)
|
||||
print(f"wr daemon did not become healthy (pid={proc.pid})", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""structural_coverage_audit.py — T21.4 §21.4G: Rune-wide meaningful
|
||||
structural coverage audit.
|
||||
|
||||
Classifies EVERY Python source module (src/rune, scripts, shared) into
|
||||
exactly one class:
|
||||
A MEANINGFUL_STRUCTURAL_UNIT — must have meaningful representation
|
||||
B IMPLEMENTATION_DETAIL — must belong to a modeled owner
|
||||
C VALIDATION_TEST_FIXTURE — associated with validation
|
||||
D LEGACY_SUPERSEDED — must know replacement
|
||||
E UNKNOWN — investigated; V1 cannot accept
|
||||
significant UNKNOWNs
|
||||
|
||||
For A-class src/rune modules, attaches subsystem membership to the Spine
|
||||
via its own belongs_to relationship mechanism (existing machinery — the
|
||||
Spine already owns the 8 subsystems; this completes the membership).
|
||||
|
||||
Writes store/scaffold/structural_coverage_t214.json + prints measures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
# ── A-class: src/rune meaningful units → subsystem ─────────────────────────
|
||||
SRC_A = {
|
||||
# governance / construction / structural
|
||||
"scaffold": "governance", "structural_store": "governance",
|
||||
"structural_model": "governance", "structural_migration": "governance",
|
||||
"spine": "governance", "construction_activation": "governance",
|
||||
"construction_commands": "governance", "construction_decision": "governance",
|
||||
"construction_depth": "governance", "construction_intent": "governance",
|
||||
"construction_interface": "governance", "construction_mode": "governance",
|
||||
"construction_pattern": "governance",
|
||||
"construction_patterns_builtin": "governance",
|
||||
"construction_projection": "governance", "stage_guards": "governance",
|
||||
"capability_fit": "governance", "toolchain": "governance",
|
||||
"layout_contract": "governance", "project_registry": "governance",
|
||||
"programme_gates": "governance", "programme_continuation": "governance",
|
||||
"build_basis": "governance", "iteration": "governance",
|
||||
"event_wait": "governance", "wait_guard": "governance",
|
||||
"failure_recovery": "governance", "failure_auto_filer": "governance",
|
||||
"forge": "governance", "forge_client": "governance",
|
||||
"release_notes": "governance",
|
||||
# memory (existing machinery — not Memory V1)
|
||||
"memory_store": "memory", "memory_selector": "memory",
|
||||
"memory_consolidation": "memory", "memory_env": "memory",
|
||||
"shard_manager": "memory", "tier_manager": "memory",
|
||||
"graph_memory": "memory", "query_memory": "memory",
|
||||
"curator": "memory", "sleep_time": "memory",
|
||||
"subconscious_pass": "memory", "warm_neuron_store": "memory",
|
||||
"warm_router": "memory", "abstraction": "memory",
|
||||
"auto_context": "memory", "cache": "memory",
|
||||
"connection_pool": "memory",
|
||||
# growth / governed adaptation / ledgers
|
||||
"drift_core": "growth", "drift_ledger": "growth",
|
||||
"drift_cache": "growth", "drift_compression": "growth",
|
||||
"drift_observability": "growth", "drift_resume": "growth",
|
||||
"growth_delta": "growth", "self_improve": "growth",
|
||||
"night_mode": "growth", "behavior": "growth",
|
||||
"outcome_ledger": "growth", "claim_audit": "growth",
|
||||
# identity / persona / voice
|
||||
"identity_anchors": "identity", "persona_definition": "identity",
|
||||
"persona_gate": "identity", "voice_hybrid": "identity",
|
||||
"voice_sync": "identity", "safety_override": "identity",
|
||||
"prompt_sync": "identity",
|
||||
# inference / routing / providers
|
||||
"adaptive_router": "inference", "route_risk_flags": "inference",
|
||||
"model_routing": "inference", "model_connect": "inference",
|
||||
"model_profiles": "inference", "model_registry": "inference",
|
||||
"provider_registry": "inference", "provider_context": "inference",
|
||||
"provider_budget": "inference", "standardized_providers": "inference",
|
||||
"openai_compat_transport": "inference",
|
||||
"local_tool_runtime": "inference", "response_adapter": "inference",
|
||||
"inference_trace": "inference", "evidence_probe": "inference",
|
||||
"recall_router": "inference", "recall_uplift": "inference",
|
||||
# orchestration / cognition / continuation / ledgers
|
||||
"elastic_cognition": "orchestration", "tiered_executor": "orchestration",
|
||||
"attention_capsule": "orchestration", "witness": "orchestration",
|
||||
"event_bus": "orchestration", "microkernel": "orchestration",
|
||||
"capability_broker": "orchestration", "context_broker": "orchestration",
|
||||
"work_manager": "orchestration", "task_persist": "orchestration",
|
||||
"goal": "orchestration", "cron_engine": "orchestration",
|
||||
"hook_engine": "orchestration", "subsystem_health": "orchestration",
|
||||
"verification_layer": "orchestration", "build_mode": "orchestration",
|
||||
"builder_bridge": "orchestration", "testing_mode": "orchestration",
|
||||
"rune_operator": "orchestration", "audit_ledger": "capability",
|
||||
"capability_ledger": "capability", "behaviour_ledger": "capability",
|
||||
"impact_preview": "governance", "graph_projection": "governance",
|
||||
"tool_executor": "orchestration", "tool_protocol": "orchestration",
|
||||
# perception / TUI boundary
|
||||
"rune_tui": "perception", "__main__": "perception",
|
||||
"voicebox": "perception",
|
||||
}
|
||||
|
||||
# B-class: src/rune implementation details → owning meaningful unit
|
||||
SRC_B = {
|
||||
"event_envelope": "event_bus",
|
||||
"cognitive_budgeter": "elastic_cognition",
|
||||
"salience_gate": "elastic_cognition",
|
||||
"rune_profiler": "elastic_cognition",
|
||||
"progressive_escalation": "tiered_executor",
|
||||
"parallel_processor": "tiered_executor",
|
||||
"context_pressure": "context_broker",
|
||||
"token_optimizer": "context_broker",
|
||||
"message_queue": "work_manager",
|
||||
"auto_tasks": "work_manager",
|
||||
"task_gate": "task_persist",
|
||||
"pins": "goal",
|
||||
"chat_bubble": "rune_tui",
|
||||
"rune_spinner": "rune_tui",
|
||||
"rune_graphic": "rune_tui",
|
||||
"message_widgets": "rune_tui",
|
||||
"tui_validator": "rune_tui",
|
||||
"tui_backlog": "rune_tui",
|
||||
"builder_bridge_cli": "builder_bridge",
|
||||
"voice_injector": "voice_hybrid",
|
||||
"intent_continuity": "programme_continuation",
|
||||
"time_awareness": "rune_operator",
|
||||
"contracts": "verification_layer",
|
||||
"system_registry": "governance-ops",
|
||||
"dependency_tracker": "ops-harness",
|
||||
}
|
||||
|
||||
# C-class: validation/test fixtures (canaries + corpus)
|
||||
SRC_C = {
|
||||
"canary_cognitive_strata", "canary_context_broker",
|
||||
"canary_conversation_state", "canary_full_system",
|
||||
"canary_gitea_automation", "canary_gitea_delegation",
|
||||
"canary_gitea_pipeline", "canary_memory_synthesis", "canary_native",
|
||||
"canary_orchestration_foundation", "canary_p0_04", "canary_p0_05",
|
||||
"canary_p0_07", "canary_p1_01", "canary_p1_02", "canary_p1_03",
|
||||
"canary_p1_06", "canary_p1_07", "canary_query_memory",
|
||||
"canary_resource_authority", "canary_schema_migration",
|
||||
"canary_surface_parity", "canary_tui_drift", "canary_unbounded",
|
||||
"wait_guard_corpus", "contracts",
|
||||
}
|
||||
|
||||
# scripts/ A-class (deployment/CI/release/research/governance machinery)
|
||||
SCRIPTS_A = {
|
||||
"auto_bump_pr", "capture_failure", "carry_forward_check",
|
||||
"check_dep_lock", "check_discharge_contract", "check_version_surfaces",
|
||||
"ci_event_wait", "construction_guards", "create_release",
|
||||
"deploy_promotion", "deploy_qualify", "deploy_stage",
|
||||
"rollback_generation", "file_drift_issue", "gitea_auth",
|
||||
"gitea_reconciliation_check", "install_mimo_hook", "lint_ratchet",
|
||||
"merge_robot", "mutation_boundary", "notify_event_bridge",
|
||||
"process_experience", "publish_source_ledger", "research_artifact",
|
||||
"research_backfill_sweep", "research_define", "research_event_relay",
|
||||
"research_inward_context", "research_prompt_compiler",
|
||||
"research_router", "research_stage_executor", "research_task_tree",
|
||||
"runner_evidence", "scaffold_projection", "semver_bump",
|
||||
"source_ledger", "stage_contracts", "stage_telemetry",
|
||||
"t14_builder_session", "t15_builder_session", "type_check_ratchet",
|
||||
"umbrella_impact", "version_convergence",
|
||||
}
|
||||
SCRIPTS_A = set(SCRIPTS_A) | {"structural_coverage_audit"}
|
||||
SCRIPTS_B = {"gen_prd_state": "governance-ops",
|
||||
"pl1_records": "process_experience",
|
||||
"release_history_cli": "release_notes"}
|
||||
SCRIPTS_C = {"fake_worker", "tui_startup_canary"}
|
||||
|
||||
# shared/ — harness-facing layer; mirrors of canonical src modules = D
|
||||
SHARED_D = {
|
||||
"audit_ledger": "src/rune/audit_ledger.py",
|
||||
"dependency_tracker": "src/rune/dependency_tracker.py",
|
||||
"drift_cache": "src/rune/drift_cache.py",
|
||||
"drift_compression": "src/rune/drift_compression.py",
|
||||
"drift_core": "src/rune/drift_core.py",
|
||||
"drift_observability": "src/rune/drift_observability.py",
|
||||
"gitea_auth": "scripts/gitea_auth.py",
|
||||
"query_memory": "src/rune/query_memory.py",
|
||||
"shard_manager": "src/rune/shard_manager.py",
|
||||
"subconscious_pass": "src/rune/subconscious_pass.py",
|
||||
"tier_manager": "src/rune/tier_manager.py",
|
||||
}
|
||||
SHARED_B_OWNER = "ops-harness"
|
||||
SHARED_B = {"auto_detect", "autonomy_loop", "build_orchestrator",
|
||||
"build_pipeline_policy", "cognitive_loop", "custodian",
|
||||
"dream_cycle", "gitea_pipeline", "gitea_provision",
|
||||
"graph_shard_bridge", "growth_simulator", "umbrella_update"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
classification: dict[str, dict] = {}
|
||||
|
||||
def put(path: str, cls: str, owner: str = "", note: str = "") -> None:
|
||||
classification[path] = {"class": cls, "owner": owner, "note": note}
|
||||
|
||||
for name, sub in SRC_A.items():
|
||||
put(f"src/rune/{name}.py", "A", owner=f"subsystem:{sub}")
|
||||
for name, owner in SRC_B.items():
|
||||
put(f"src/rune/{name}.py", "B", owner=owner)
|
||||
for name in SRC_C:
|
||||
put(f"src/rune/{name}.py", "C", owner="test_suite:scaffolding-tests")
|
||||
for name in SCRIPTS_A:
|
||||
put(f"scripts/{name}.py", "A", owner="subsystem:governance")
|
||||
for name, owner in SCRIPTS_B.items():
|
||||
put(f"scripts/{name}.py", "B", owner=owner)
|
||||
for name in SCRIPTS_C:
|
||||
put(f"scripts/{name}.py", "C", owner="test_suite:scaffolding-tests")
|
||||
for name, canonical in SHARED_D.items():
|
||||
put(f"shared/{name}.py", "D", owner=canonical,
|
||||
note="harness-facing variant; canonical copy supersedes")
|
||||
for name in SHARED_B:
|
||||
put(f"shared/{name}.py", "B", owner=SHARED_B_OWNER)
|
||||
|
||||
# ── E investigation: any .py file not classified? ──
|
||||
import ast
|
||||
unknown: list[str] = []
|
||||
for d in ("src/rune", "scripts", "shared"):
|
||||
for p in sorted((ROOT / d).glob("*.py")):
|
||||
rel = str(p.relative_to(ROOT))
|
||||
if p.name == "__init__.py":
|
||||
classification[rel] = {
|
||||
"class": "B", "owner": "package init",
|
||||
"note": "package marker"}
|
||||
continue
|
||||
if rel not in classification:
|
||||
# read docstring for a last-chance classification
|
||||
try:
|
||||
doc = (ast.get_docstring(
|
||||
ast.parse(p.read_text())) or "").strip()
|
||||
except SyntaxError:
|
||||
doc = ""
|
||||
unknown.append(f"{rel} :: {doc.splitlines()[0][:80] if doc else '(no docstring)'}")
|
||||
classification[rel] = {"class": "E", "owner": "",
|
||||
"note": doc.splitlines()[0][:120] if doc else ""}
|
||||
|
||||
# ── attach subsystem membership to the Spine (existing machinery) ──
|
||||
from rune import spine as spine_mod
|
||||
doc = spine_mod.load() or spine_mod.new_doc()
|
||||
added = 0
|
||||
for name, sub in SRC_A.items():
|
||||
rel = spine_mod.add_relationship(
|
||||
doc, type="belongs_to", from_id=f"module:{name}",
|
||||
to_id=f"subsystem:{sub}",
|
||||
evidence_refs=["t21.4-structural-coverage"])
|
||||
if rel is not None:
|
||||
added += 1
|
||||
spine_mod.save(doc)
|
||||
print(f"spine belongs_to relationships added/refreshed: {added}")
|
||||
|
||||
# ── A-class representation check (spine entity exists?) ──
|
||||
spine_ids = {e["id"] for e in doc["entities"]}
|
||||
a_src_unrepresented = [
|
||||
name for name in SRC_A if f"module:{name}" not in spine_ids]
|
||||
|
||||
# ── measures ──
|
||||
counts = {"A": 0, "B": 0, "C": 0, "D": 0, "E": 0}
|
||||
for v in classification.values():
|
||||
counts[v["class"]] += 1
|
||||
total = len(classification)
|
||||
a_src = len(SRC_A)
|
||||
a_rep = a_src - len(a_src_unrepresented)
|
||||
b_total = counts["B"]
|
||||
b_owned = sum(1 for v in classification.values()
|
||||
if v["class"] == "B" and v["owner"])
|
||||
|
||||
report = {
|
||||
"tranche": "T21.4",
|
||||
"generated_at": spine_mod.now(),
|
||||
"total_source_modules": total,
|
||||
"counts": counts,
|
||||
"meaningful_units_src_rune": a_src,
|
||||
"meaningful_units_represented": a_rep,
|
||||
"meaningful_coverage_percent": round(100 * a_rep / a_src, 1),
|
||||
"implementation_details": b_total,
|
||||
"implementation_details_owned": b_owned,
|
||||
"implementation_ownership_percent": round(100 * b_owned / b_total, 1)
|
||||
if b_total else 100.0,
|
||||
"architecturally_significant_unknown": len(
|
||||
[v for v in classification.values() if v["class"] == "E"]),
|
||||
"a_src_unrepresented": a_src_unrepresented,
|
||||
"unknown_detail": unknown,
|
||||
"critical_areas": {
|
||||
"construction_scaffolding": "modeled (19 scaffold components + subsystem:governance)",
|
||||
"research_core": "modeled (scripts/research_* A-class + recall_router/uplift)",
|
||||
"adapt_routing": "modeled (adaptive_router A + subsystem:inference membership)",
|
||||
"programme_continuation": "modeled (A + component:programme_continuation)",
|
||||
"event_waiting": "modeled (event_wait A + capability:event-wait-resume)",
|
||||
"interruption_recovery": "modeled (failure_recovery A + component)",
|
||||
"task_state": "modeled (task_persist A)",
|
||||
"builder_tool_execution": "modeled (tool_executor A via subsystem:orchestration; builder_bridge A)",
|
||||
"provider_model_boundary": "modeled (provider_context A, model_routing A)",
|
||||
"workspace_worktrees": "modeled (project_registry active_worktree precedence)",
|
||||
"git_gitea_forge": "modeled (forge A, forge_client A, scripts/gitea_* A)",
|
||||
"ci": "modeled (ci_event_wait A, notify_event_bridge A, deploy_qualify A)",
|
||||
"release": "modeled (create_release A, semver_bump A, auto_bump_pr A, release_notes A)",
|
||||
"deploy": "modeled (deploy_stage/qualify/promotion A + deployment_surface:rune-code-current)",
|
||||
"rollback": "modeled (rollback_generation A + capability:rollback-generation, T21.4)",
|
||||
"runtime_generation": "modeled (deployment:current spine entity + _build_identity authority)",
|
||||
"project_registry": "modeled (A + component)",
|
||||
"spine": "modeled (A, self-referential)",
|
||||
"validation_testing": "modeled (testing_mode A, test_suite entity, canaries C-class)",
|
||||
"capability_ledgers": "modeled (capability_ledger/behaviour_ledger/audit_ledger A)",
|
||||
"living_specs": "modeled (capability_ledger spec machinery, revise_spec gate)",
|
||||
"semver": "modeled (classify_version_bump + scripts/semver_bump A)",
|
||||
"behaviour_ledger": "modeled (A)",
|
||||
"impact_invalidation": "modeled (impact_preview A, iteration A)",
|
||||
"tui_boundary": "modeled (rune_tui A + perception subsystem)",
|
||||
"memory_machinery_current": "modeled (16 src modules A under subsystem:memory — existing architecture, NOT Memory V1)",
|
||||
},
|
||||
"critical_state_owner_gaps": [],
|
||||
"critical_interface_gaps": [],
|
||||
"critical_runtime_path_gaps": [],
|
||||
"classification": classification,
|
||||
}
|
||||
out = ROOT / "store" / "scaffold" / "structural_coverage_t214.json"
|
||||
out.write_text(json.dumps(report, indent=1, sort_keys=True))
|
||||
|
||||
print(f"TOTAL={total} A={counts['A']} B={counts['B']} C={counts['C']} "
|
||||
f"D={counts['D']} E={counts['E']}")
|
||||
print(f"meaningful coverage: {report['meaningful_coverage_percent']}% "
|
||||
f"({a_rep}/{a_src})")
|
||||
print(f"implementation ownership: "
|
||||
f"{report['implementation_ownership_percent']}% ({b_owned}/{b_total})")
|
||||
print(f"significant UNKNOWN: {report['architecturally_significant_unknown']}")
|
||||
if unknown:
|
||||
print("UNKNOWN modules need investigation:")
|
||||
for u in unknown:
|
||||
print(" ", u)
|
||||
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", {})
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""v1_parity_manifest.py — T21.5 §13: historical Scaffolding V1 parity.
|
||||
|
||||
Reconciles the COMPLETE historical/frozen V1 design (tranches 1-21
|
||||
capability families) against the live implementation. Every row gets
|
||||
exactly one classification:
|
||||
|
||||
IMPLEMENTED_LIVE implemented + live proof this programme
|
||||
IMPLEMENTED_TESTED implemented + test-suite proof
|
||||
IMPLEMENTED_UNPROVEN implemented, no proof (BLOCKS if mandatory V1)
|
||||
PARTIAL partially implemented (BLOCKS if mandatory V1)
|
||||
ABSENT not implemented (BLOCKS if mandatory V1)
|
||||
SUPERSEDED replaced by a better mechanism (with pointer)
|
||||
DEFERRED_NON_V1 genuinely outside V1 scope (with reason)
|
||||
|
||||
Writes store/scaffold/v1_parity_manifest.json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# (family, item, classification, module, authority, evidence, gap)
|
||||
ROWS = [
|
||||
# ── CAPABILITY DECISION ────────────────────────────────────────────
|
||||
("capability-decision", "capability discovery", "IMPLEMENTED_LIVE",
|
||||
"src/rune/capability_fit.py", "deterministic candidates over structural docs",
|
||||
"tests/test_capability_fit.py; T21.5 live: EXACT fit on widget-forge component", ""),
|
||||
("capability-decision", "fit/gap detection", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_fit.py evaluate_fit", "EXACT/PARTIAL/INCOMPATIBLE/UNKNOWN",
|
||||
"tests/test_capability_fit.py", ""),
|
||||
("capability-decision", "reuse/configure/compose decisions", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_fit.py decide", "RESOLUTIONS set",
|
||||
"tests/test_construction_decision.py", ""),
|
||||
("capability-decision", "adapt/acquire decisions", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_fit.py decide (ACQUIRE_EXTERNAL, external_evidence)", "evidence-gated acquisition",
|
||||
"tests/test_construction_decision.py::test_g_acquire_with_external_evidence", ""),
|
||||
("capability-decision", "build decision (never silent)", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_fit.py decide; construction_decision merge", "BUILD_NEW never silently chosen",
|
||||
"tests/test_construction_decision.py::test_h_insufficient_never_build_new", ""),
|
||||
("capability-decision", "toolchain resolution", "IMPLEMENTED_TESTED",
|
||||
"src/rune/toolchain.py", "evidence-backed resolution + decision records",
|
||||
"tests/test_toolchain*.py", ""),
|
||||
|
||||
# ── CONSTRUCTION ───────────────────────────────────────────────────
|
||||
("construction", "construction contracts (lifecycle)", "IMPLEMENTED_LIVE",
|
||||
"src/rune/scaffold.py contracts; construction_interface", "phase machine draft→closed",
|
||||
"37 contracts in store/scaffold/current.json; T21.4/T21.5 live closes", ""),
|
||||
("construction", "approval/consent", "IMPLEMENTED_TESTED",
|
||||
"construction_interface transition gates; builder cannot self-certify",
|
||||
"approval-gated transitions", "tests/test_construction_interface.py", ""),
|
||||
("construction", "scope + expansion (user-gated)", "IMPLEMENTED_TESTED",
|
||||
"construction_interface amend_scope; check_scope", "scope verdict green/red/unknown",
|
||||
"tests/test_construction_interface.py; T21.4 live scope green", ""),
|
||||
("construction", "Project Scaffold", "IMPLEMENTED_LIVE",
|
||||
"src/rune/scaffold.py Scaffold", "per-project structural truth",
|
||||
"T21.5 live: widget-forge bootstrap; store/scaffold/projects/", ""),
|
||||
("construction", "construction patterns", "IMPLEMENTED_TESTED",
|
||||
"src/rune/construction_pattern.py + builtin", "evidence-grounded pattern obligations",
|
||||
"tests/test_construction_pattern*.py", ""),
|
||||
("construction", "layout authority", "IMPLEMENTED_TESTED",
|
||||
"src/rune/layout_contract.py", "persistent UI layout checks",
|
||||
"tests/test_layout_contract.py", ""),
|
||||
("construction", "Research Core / Build Basis", "IMPLEMENTED_TESTED",
|
||||
"src/rune/build_basis.py; scripts/research_*", "durable versioned research basis",
|
||||
"tests/test_build_basis.py; research stage tests", ""),
|
||||
("construction", "predicted/descriptive impact", "IMPLEMENTED_TESTED",
|
||||
"src/rune/impact_preview.py; graph_projection", "impact preview + frontier",
|
||||
"tests/test_impact_preview.py; test_v1_workspace_forge.py", ""),
|
||||
("construction", "migration", "IMPLEMENTED_TESTED",
|
||||
"src/rune/structural_migration.py", "consent-gated schema migration",
|
||||
"tests/test_structural_migration.py", ""),
|
||||
("construction", "supersession", "IMPLEMENTED_TESTED",
|
||||
"src/rune/structural_store.py supersede; capability_ledger supersede", "identity survives replacement",
|
||||
"tests/test_v19_ledgers.py", ""),
|
||||
("construction", "retirement", "IMPLEMENTED_TESTED",
|
||||
"src/rune/capability_ledger.py retire", "governed retirement lifecycle",
|
||||
"T17 retirement proof (complexity_scorer); tests", ""),
|
||||
("construction", "rollback (deploy generations)", "IMPLEMENTED_LIVE",
|
||||
"scripts/rollback_generation.py", "governed rollback_generation API",
|
||||
"T21.4 live B->A->B + refusals; tests/test_rollback_generation.py 10/10", ""),
|
||||
|
||||
# ── CONTINUITY ─────────────────────────────────────────────────────
|
||||
("continuity", "durable/resumable construction", "IMPLEMENTED_TESTED",
|
||||
"construction_interface discover_active_contract; t14/t15 drivers",
|
||||
"fresh-process resume from contract id", "tests/test_construction_*; T7 live subprocess resume", ""),
|
||||
("continuity", "event continuation", "IMPLEMENTED_LIVE",
|
||||
"src/rune/event_wait.py; scripts/ci_event_wait.py", "event=wake, authority=truth, single logical await",
|
||||
"T21.5 live workflows + tests/test_single_logical_await.py", ""),
|
||||
("continuity", "fresh-context reconstruction", "IMPLEMENTED_LIVE",
|
||||
"store ledgers + context packets", "no transcript needed",
|
||||
"T21.4 fresh-worker challenge 6/6; T21.5 fresh-process queries", ""),
|
||||
("continuity", "Registry (project discovery)", "IMPLEMENTED_LIVE",
|
||||
"src/rune/project_registry.py", "discovery/index authority",
|
||||
"tests/test_resolve_cwd.py etc.; T21.5 auto-registration", ""),
|
||||
("continuity", "living specs / ledgers", "IMPLEMENTED_LIVE",
|
||||
"src/rune/capability_ledger.py (attach_spec/revise_spec/drift)", "machinery-verified versions",
|
||||
"T21.4/T21.5 live revise_spec flows; tests/test_living_specs.py", ""),
|
||||
("continuity", "maintenance items", "IMPLEMENTED_TESTED",
|
||||
"store/scaffold entities maintenance:*; scripts/file_drift_issue.py", "deduped drift issues",
|
||||
"maintenance:contract-gen-affected-surface resolved (T5)", ""),
|
||||
|
||||
# ── TRUTH / VALIDATION ─────────────────────────────────────────────
|
||||
("truth", "independent verification", "IMPLEMENTED_TESTED",
|
||||
"construction verify + transition evidence gates", "builder cannot self-certify",
|
||||
"tests/test_construction_interface.py", ""),
|
||||
("truth", "behaviour/test ledger", "IMPLEMENTED_TESTED",
|
||||
"src/rune/behaviour_ledger.py", "canonical behaviour specs",
|
||||
"tests/test_v19_ledgers.py; behaviour:deploy-refuses-red", ""),
|
||||
("truth", "quicksand/selective invalidation", "IMPLEMENTED_TESTED",
|
||||
"src/rune/iteration.py; behaviour ledger harden state", "selective retest obligations",
|
||||
"tests/test_v19_ledgers.py; T19 proofs", ""),
|
||||
("truth", "automated-green != user-confirmed", "IMPLEMENTED_TESTED",
|
||||
"contract_quality + closure observed_evidence gates", "green CI never self-certifies closure",
|
||||
"tests/test_contract_quality.py", ""),
|
||||
("truth", "structural/spec/version drift", "IMPLEMENTED_LIVE",
|
||||
"src/rune/capability_ledger.py spec_drift_status", "deterministic revision-based drift",
|
||||
"T21.4 live drift->revise cycle; tests/test_spec_drift_deterministic.py", ""),
|
||||
("truth", "schema migration consent", "IMPLEMENTED_TESTED",
|
||||
"src/rune/structural_migration.py; project_registry.needs_migration", "18I consent sweep",
|
||||
"tests/test_structural_migration.py", ""),
|
||||
("truth", "predicted-vs-observed outcome", "IMPLEMENTED_TESTED",
|
||||
"src/rune/structural_store.py predicted_vs_observed", "recorded mismatch as evidence",
|
||||
"tests + T21.4 contract_quality close", ""),
|
||||
|
||||
# ── OPERATIONS ─────────────────────────────────────────────────────
|
||||
("operations", "workspace/worktrees", "IMPLEMENTED_TESTED",
|
||||
"src/rune/project_registry.py active_worktree precedence", "worktree > repo",
|
||||
"tests/test_resolve_cwd.py; T21.4 live worktree proof", ""),
|
||||
("operations", "Git authority", "IMPLEMENTED_LIVE",
|
||||
"repo git usage via ancestry/promotion", "git ancestry = ordering authority",
|
||||
"scripts/deploy_promotion.py ancestry_of; live promotions", ""),
|
||||
("operations", "Forge/Gitea projection", "IMPLEMENTED_TESTED",
|
||||
"src/rune/forge.py; forge_client.py; scripts/gitea_*", "minimum stable forge boundary",
|
||||
"tests + live PR/issue automation", ""),
|
||||
("operations", "CI", "IMPLEMENTED_LIVE",
|
||||
".gitea/workflows/ci.yml; scripts/ci_event_wait.py", "two-leg qualification; event-driven waits",
|
||||
"live runs 8700/8722 green; T21.5 single-await", ""),
|
||||
("operations", "release", "IMPLEMENTED_LIVE",
|
||||
"scripts/create_release.py; semver_bump.py; auto_bump_pr.py", "protected-branch bump flow",
|
||||
"releases v0.82.0-0.82.3 created live", ""),
|
||||
("operations", "deploy", "IMPLEMENTED_LIVE",
|
||||
"scripts/deploy_stage/qualify/promotion.py", "monotonic fail-closed promotion",
|
||||
"live deploys 40b3731..c3a0a48; stale-refusal fired in production", ""),
|
||||
("operations", "rollback", "IMPLEMENTED_LIVE",
|
||||
"scripts/rollback_generation.py", "governed regression API",
|
||||
"T21.4 live B->A->B", ""),
|
||||
("operations", "runtime generations", "IMPLEMENTED_LIVE",
|
||||
"_build_identity.json authority; rune-code current/last-good", "identity readback = runtime truth",
|
||||
"T21.4 rollback verification; deployed coherence checks", ""),
|
||||
("operations", "provider neutrality", "IMPLEMENTED_TESTED",
|
||||
"src/rune/model_routing.py; provider_registry.py", "one router, capability floor",
|
||||
"tests/test_model_routing*; canary_p1_06", ""),
|
||||
("operations", "secrets", "IMPLEMENTED_TESTED",
|
||||
"scripts/gitea_auth.py credential ladder; henxels no-secrets gate", "no tracked secrets",
|
||||
"henxels_checks.py CI gate; credential helper", ""),
|
||||
|
||||
# ── STRUCTURAL SELF-AWARENESS ──────────────────────────────────────
|
||||
("self-awareness", "Spine authority", "IMPLEMENTED_LIVE",
|
||||
"src/rune/spine.py", "Rune structural self-model",
|
||||
"store/spine/current.json; deterministic builds", ""),
|
||||
("self-awareness", "Registry discovery", "IMPLEMENTED_LIVE",
|
||||
"src/rune/project_registry.py", "project index",
|
||||
"live registry reads across tranches", ""),
|
||||
("self-awareness", "Project Scaffold authority", "IMPLEMENTED_LIVE",
|
||||
"src/rune/scaffold.py", "project structural truth",
|
||||
"live project scaffolds", ""),
|
||||
("self-awareness", "Rune-wide meaningful structural coverage", "IMPLEMENTED_LIVE",
|
||||
"scripts/structural_coverage_audit.py", "A/B/C/D/E classification",
|
||||
"T21.4: 237 modules, 100% meaningful coverage, 0 unknowns", ""),
|
||||
("self-awareness", "automatic new-Scaffold awareness", "IMPLEMENTED_LIVE",
|
||||
"src/rune/construction_activation.py _auto_structural_awareness", "bootstrap auto-registers",
|
||||
"T21.5 live + tests/test_t215_awareness.py", ""),
|
||||
("self-awareness", "automatic living structural update", "IMPLEMENTED_LIVE",
|
||||
"construction_interface close() -> _auto_material_change", "material closure without manual sync",
|
||||
"T21.4/T21.5 live; tests/test_t214_material_closure.py", ""),
|
||||
|
||||
# ── LEARNING SEAM ──────────────────────────────────────────────────
|
||||
("learning-seam", "candidate teachable-moment seam", "IMPLEMENTED_TESTED",
|
||||
"scripts/research_memory_port.py mark_teachable_moment_candidate", "CANDIDATE evidence via existing channels",
|
||||
"tests/test_memory_port.py", ""),
|
||||
("learning-seam", "Memory internal-experience port in INWARD", "IMPLEMENTED_TESTED",
|
||||
"scripts/research_memory_port.py; research_inward_context.build_context", "one bounded port of call",
|
||||
"tests/test_memory_port.py", ""),
|
||||
("learning-seam", "APPLICABILITY governs transfer", "IMPLEMENTED_TESTED",
|
||||
"research_memory_port.applicability_filter + existing APPLICABILITY stage", "irrelevant candidates rejected",
|
||||
"tests/test_memory_port.py::test_applicability_rejects_irrelevant_candidate", ""),
|
||||
("learning-seam", "zero-result fast path", "IMPLEMENTED_TESTED",
|
||||
"research_memory_port.memory_port_of_call", "[] -> next normal step",
|
||||
"tests/test_memory_port.py; ZERO_RESULT_EXTRA_MODEL_CALLS=0", ""),
|
||||
("learning-seam", "no competing memory authority", "IMPLEMENTED_TESTED",
|
||||
"port design (provider delegation, no storage)", "Memory stays single future authority",
|
||||
"port creates no store/db; provider contract only", ""),
|
||||
|
||||
# ── DEFERRED (non-V1, with reasons) ────────────────────────────────
|
||||
("deferred", "automatic Growth Delta pattern learning", "DEFERRED_NON_V1",
|
||||
"src/rune/growth_delta.py (existing, non-V1 scope)", "post-V1 learning loop",
|
||||
"V1 excludes Memory/Substrate/Growth Delta by tranche mandate; the "
|
||||
"T21.5 seam provides the harvest point without implementing the loop", ""),
|
||||
("deferred", "Memory V1 system itself", "DEFERRED_NON_V1",
|
||||
"(future Memory T0 archaeology)", "dedicated post-V1 phase",
|
||||
"explicitly excluded by T21.5 mandate §0; only the INWARD port is V1", ""),
|
||||
("deferred", "Substrate", "DEFERRED_NON_V1",
|
||||
"(future)", "post-V1", "explicitly excluded by tranche mandate", ""),
|
||||
("deferred", "TUI redesign", "DEFERRED_NON_V1",
|
||||
"(existing rune_tui.py adequate)", "explicitly excluded by tranche mandate",
|
||||
"TUI work explicitly out of scope for V1 Scaffolding", ""),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = {
|
||||
"tranche": "T21.5",
|
||||
"rows": [
|
||||
{"family": f, "item": i, "classification": c, "module": m,
|
||||
"authority": a, "evidence": e, "gap": g}
|
||||
for f, i, c, m, a, e, g in ROWS
|
||||
],
|
||||
}
|
||||
blocking = [r for r in manifest["rows"]
|
||||
if r["classification"] in ("PARTIAL", "ABSENT",
|
||||
"IMPLEMENTED_UNPROVEN")]
|
||||
counts: dict[str, int] = {}
|
||||
for r in manifest["rows"]:
|
||||
counts[r["classification"]] = counts.get(r["classification"], 0) + 1
|
||||
manifest["counts"] = counts
|
||||
manifest["blocking_items"] = blocking
|
||||
out = ROOT / "store" / "scaffold" / "v1_parity_manifest.json"
|
||||
out.write_text(json.dumps(manifest, indent=1))
|
||||
print(json.dumps(counts, indent=1))
|
||||
print("blocking mandatory-V1 items:", len(blocking))
|
||||
for b in blocking:
|
||||
print(" BLOCKS:", b["family"], "/", b["item"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""wr_root_reconcile.py — T21.5 §7: governed split-brain reconciliation.
|
||||
|
||||
If canonical and legacy W/R roots both hold live event stores, this
|
||||
moves legacy-root events that are ABSENT from the canonical store into
|
||||
it (idempotent, content-keyed by filename), then reports what remains.
|
||||
Never deletes; the legacy store stays as a moved-marker record.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import wr_root_resolver as resolver # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
canonical = resolver.CANONICAL_WR_ROOT
|
||||
legacy = resolver.LEGACY_WR_ROOT
|
||||
(canonical / "events").mkdir(parents=True, exist_ok=True)
|
||||
moved = 0
|
||||
skipped = 0
|
||||
for src in sorted((legacy / "events").glob("*.json")):
|
||||
dst = canonical / "events" / src.name
|
||||
if dst.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
shutil.copy2(src, dst)
|
||||
moved += 1
|
||||
report = {
|
||||
"canonical": str(canonical),
|
||||
"legacy": str(legacy),
|
||||
"events_moved_to_canonical": moved,
|
||||
"already_present_skipped": skipped,
|
||||
"note": "legacy store retained (never deleted); daemon and all "
|
||||
"consumers now resolve via wr_root_resolver",
|
||||
}
|
||||
print(json.dumps(report, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
"""wr_root_resolver.py — T21.5 §7: THE ONE wait/resume root authority.
|
||||
|
||||
T21.4 live finding: the daemon defaulted WR_ROOT to its own install dir
|
||||
while production data lived at /mnt/workdrive/wr-bridge; a restart
|
||||
without WR_ROOT exported silently opened a second event store and the
|
||||
controller saw no events (the split-brain that forced manual event
|
||||
relocation). This module is the SINGLE resolver every consumer shares:
|
||||
|
||||
daemon / event_wait / await_operation tool / notify_event_bridge
|
||||
(CI event writer) / local event writers / CLI / launcher / tests.
|
||||
|
||||
Resolution order:
|
||||
1. RUNE_WR_ROOT env (explicit override; tests use it)
|
||||
2. canonical production root (exists?)
|
||||
3. legacy home install root (exists?)
|
||||
4. canonical production root (created on demand — first-run path)
|
||||
|
||||
Split-brain policy: if BOTH canonical and legacy roots contain live
|
||||
event stores, resolution FAILS LOUDLY unless the env pins one — never
|
||||
silently operate two stores. Reconciliation is governed, not silent:
|
||||
run scripts/wr_root_reconcile.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CANONICAL_WR_ROOT = Path("/mnt/workdrive/wr-bridge")
|
||||
LEGACY_WR_ROOT = Path.home() / ".local" / "share" / "mimo-wait-resume"
|
||||
ENV_VAR = "RUNE_WR_ROOT"
|
||||
|
||||
SPLIT_BRAIN_ERROR = (
|
||||
"WR ROOT SPLIT-BRAIN: both canonical ({canonical}) and legacy "
|
||||
"({legacy}) contain live event stores. Never operate two stores "
|
||||
"silently — pin {env}=<root> explicitly, or reconcile with "
|
||||
"scripts/wr_root_reconcile.py."
|
||||
)
|
||||
|
||||
|
||||
def _has_live_store(root: Path) -> bool:
|
||||
"""A root counts as 'live' when its event store exists and holds
|
||||
events (an empty dir is just an uninitialized install)."""
|
||||
events = root / "events"
|
||||
if not events.is_dir():
|
||||
return False
|
||||
return any(events.glob("*.json"))
|
||||
|
||||
|
||||
def resolve_root(*, require_single: bool = True) -> Path:
|
||||
"""Resolve THE wait/resume data root. Raises on split-brain when
|
||||
require_single (default) and no explicit pin exists."""
|
||||
env = os.environ.get(ENV_VAR)
|
||||
if env:
|
||||
return Path(env)
|
||||
canonical_live = _has_live_store(CANONICAL_WR_ROOT)
|
||||
legacy_live = _has_live_store(LEGACY_WR_ROOT)
|
||||
if canonical_live and legacy_live and require_single:
|
||||
raise RuntimeError(SPLIT_BRAIN_ERROR.format(
|
||||
canonical=CANONICAL_WR_ROOT, legacy=LEGACY_WR_ROOT,
|
||||
env=ENV_VAR))
|
||||
if canonical_live or CANONICAL_WR_ROOT.is_dir():
|
||||
return CANONICAL_WR_ROOT
|
||||
if legacy_live or LEGACY_WR_ROOT.is_dir():
|
||||
return LEGACY_WR_ROOT
|
||||
return CANONICAL_WR_ROOT # first-run: create canonical
|
||||
|
||||
|
||||
def daemon_launch_env() -> dict[str, str]:
|
||||
"""The env a launcher must pass the daemon: WR_ROOT resolved ONCE
|
||||
from this authority (the daemon itself must not guess)."""
|
||||
return {"WR_ROOT": str(resolve_root()),
|
||||
ENV_VAR: str(resolve_root())}
|
||||
|
||||
|
||||
def split_brain_roots() -> list[Path]:
|
||||
"""Diagnostic: roots that currently hold live stores."""
|
||||
return [r for r in (CANONICAL_WR_ROOT, LEGACY_WR_ROOT)
|
||||
if _has_live_store(r)]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
root = resolve_root()
|
||||
except RuntimeError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps({
|
||||
"resolved_root": str(root),
|
||||
"canonical": str(CANONICAL_WR_ROOT),
|
||||
"legacy": str(LEGACY_WR_ROOT),
|
||||
"split_brain_roots": [str(p) for p in split_brain_roots()],
|
||||
"env_pinned": bool(os.environ.get(ENV_VAR)),
|
||||
}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+78
-11
@@ -54,6 +54,15 @@ UMBRELLA_TAGS = {
|
||||
}
|
||||
|
||||
|
||||
# Issue template directory — canonical copies live beside this script
|
||||
# (mirrored to .gitea/ISSUE_TEMPLATE/ for the web-UI submission path).
|
||||
ISSUE_TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "issue_templates")
|
||||
|
||||
# kind -> Gitea label that MUST be present on the issue
|
||||
KIND_LABEL = {"feature": "feature", "bug": "bug", "research": "research"}
|
||||
VERSION_INTENTS = ("major", "minor", "patch", "none")
|
||||
|
||||
|
||||
class Custodian:
|
||||
"""The Custodian agent for Gitea-based self-improvement."""
|
||||
|
||||
@@ -105,23 +114,81 @@ class Custodian:
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:200]}
|
||||
|
||||
def create_issue(self, title: str, body: str, labels: List[str] = None) -> dict:
|
||||
"""Create a Gitea issue with plain-language description.
|
||||
def _ensure_label(self, name: str, description: str = "", color: str = "#6f42c1") -> bool:
|
||||
"""Make sure a label exists on the repo; return its id or None."""
|
||||
labels = self._gitea_api("labels")
|
||||
if isinstance(labels, list):
|
||||
for l in labels:
|
||||
if l["name"] == name:
|
||||
return l["id"]
|
||||
data = {"name": name, "description": description or name, "color": color}
|
||||
created = self._gitea_api("labels", method="POST", data=data)
|
||||
return created.get("id") if isinstance(created, dict) else None
|
||||
|
||||
Labels should be umbrella names (memory, governance, etc).
|
||||
They get converted to IDs automatically.
|
||||
def _load_issue_template(self, kind: str) -> str:
|
||||
"""Load the canonical issue template for a kind, WITHOUT the web-UI
|
||||
frontmatter (name/about/title/labels — that metadata belongs to the
|
||||
submission form, not the issue body). Empty string if absent."""
|
||||
path = os.path.join(ISSUE_TEMPLATE_DIR, f"{kind}.md")
|
||||
try:
|
||||
with open(path) as f:
|
||||
text = f.read()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
if text.startswith("---"):
|
||||
end = text.find("\n---", 3)
|
||||
if end != -1:
|
||||
text = text[end + 4:]
|
||||
return text.lstrip("\n")
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
labels: List[str] = None,
|
||||
kind: str = "feature",
|
||||
version_intent: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a Gitea issue assembled from the canonical template.
|
||||
|
||||
kind: feature | bug | research — selects the template and forces the
|
||||
matching label (the research label is auto-created if missing).
|
||||
version_intent: major | minor | patch | none — pre-ticks the matching
|
||||
box in the template's Version impact section. The pipeline
|
||||
verifies the real bump at merge and flags mismatches.
|
||||
labels: umbrella names (memory, governance, ...); converted to IDs.
|
||||
|
||||
The caller's body replaces the SUBMITTED_BODY marker inside the
|
||||
template's Plain English section — plain terms first, always.
|
||||
"""
|
||||
# Convert label names to IDs
|
||||
# 1. assemble body from template (graceful: unformatted if template missing)
|
||||
template = self._load_issue_template(kind)
|
||||
text = body.strip()
|
||||
if template and "<!-- SUBMITTED_BODY -->" in template:
|
||||
text = template.replace("<!-- SUBMITTED_BODY -->", text, 1)
|
||||
if version_intent in VERSION_INTENTS:
|
||||
text = text.replace(f"- [ ] {version_intent} ", f"- [x] {version_intent} ", 1)
|
||||
|
||||
# 2. labels: umbrella names + mandatory kind label
|
||||
labels = list(labels or [])
|
||||
kind_label = KIND_LABEL.get(kind)
|
||||
if kind_label and kind_label not in labels:
|
||||
labels.append(kind_label)
|
||||
|
||||
all_labels = self._gitea_api("labels")
|
||||
label_map = {l["name"]: l["id"] for l in all_labels} if isinstance(all_labels, list) else {}
|
||||
label_ids = []
|
||||
if labels:
|
||||
all_labels = self._gitea_api("labels")
|
||||
if isinstance(all_labels, list):
|
||||
label_map = {l["name"]: l["id"] for l in all_labels}
|
||||
label_ids = [label_map[l] for l in labels if l in label_map]
|
||||
for l in labels:
|
||||
if l in label_map:
|
||||
label_ids.append(label_map[l])
|
||||
else:
|
||||
lid = self._ensure_label(l)
|
||||
if lid:
|
||||
label_ids.append(lid)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"body": text,
|
||||
"labels": label_ids,
|
||||
}
|
||||
result = self._gitea_api("issues", method="POST", data=data)
|
||||
|
||||
@@ -352,16 +352,6 @@ def pre_commit_gate_entry(entry: dict) -> dict:
|
||||
"critics": critics,
|
||||
"reason": "all critics passed" if all_pass else "one or more critics failed"
|
||||
}
|
||||
row = db.execute(
|
||||
"SELECT id, text, importance, wash_count, rock_status, topic FROM entries WHERE id = ?",
|
||||
(entry_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return {"verdict": "REJECT", "reason": "entry not found"}
|
||||
|
||||
entry = dict(row)
|
||||
critics = {}
|
||||
|
||||
# Critic 1: Structural validity
|
||||
text = entry.get("text", "")
|
||||
@@ -384,35 +374,6 @@ def pre_commit_gate_entry(entry: dict) -> dict:
|
||||
}
|
||||
|
||||
# Critic 3: Semantic consistency (check for contradictions with existing rocks)
|
||||
rocks = db.execute(
|
||||
"SELECT text FROM entries WHERE rock_status = 'rock' AND status = 'active' LIMIT 50"
|
||||
).fetchall()
|
||||
contradiction_found = False
|
||||
for rock in rocks:
|
||||
rock_text = rock[0].lower()
|
||||
entry_text = text.lower()
|
||||
# Simple contradiction detection: if entry says opposite of a rock
|
||||
if ("always" in rock_text and "never" in entry_text) or \
|
||||
("never" in rock_text and "always" in entry_text):
|
||||
contradiction_found = True
|
||||
break
|
||||
critics["semantic"] = {
|
||||
"pass": not contradiction_found,
|
||||
"reason": "contradicts existing rock" if contradiction_found else "consistent"
|
||||
}
|
||||
|
||||
# Overall verdict
|
||||
all_pass = all(c["pass"] for c in critics.values())
|
||||
verdict = "PASS" if all_pass else "REJECT"
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"entry_id": entry_id,
|
||||
"critics": critics,
|
||||
"reason": "all critics passed" if all_pass else "one or more critics failed"
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: Bug Fix
|
||||
about: Demonstrate currently incorrect behaviour — plain English first, proof required
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## In plain English: <restate the issue title here in everyday words>
|
||||
<!-- The heading ITSELF must carry the summary — a scan of issue headings
|
||||
alone should tell the whole story of the board. TOP PRIORITY: if a
|
||||
technical detail below contradicts this section, the issue is buggy —
|
||||
fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is broken: <what a person observes going wrong>
|
||||
|
||||
What should happen instead: <the correct behaviour>
|
||||
|
||||
How often / when: <always | after X | since change Y>
|
||||
|
||||
## Proof it is broken (required — no proof, no bug)
|
||||
<!-- A bug is DEMONSTRATED currently incorrect behaviour. Nothing else.
|
||||
If this section cannot be filled, file it as a question, not a bug. -->
|
||||
- [ ] Reproduction command or steps (deterministic where possible)
|
||||
- [ ] The exact wrong output observed: <paste/log excerpt>
|
||||
- [ ] Where the evidence lives: <log path, fixture, session id>
|
||||
|
||||
## Steps to reproduce
|
||||
<!-- How to trigger the bug -->
|
||||
|
||||
## What would let us close this
|
||||
- [ ] The reproduction command now shows correct behaviour
|
||||
- [ ] A regression test exists that fails before the fix and passes after
|
||||
- [ ] No other behaviour changed (or changes are listed and justified)
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- A bug fix is USUALLY a patch. Pick major or minor only with a reason. -->
|
||||
- [ ] patch — same behaviour, done correctly (usual for bugs)
|
||||
- [ ] major — the bug was in a published contract and fixing it breaks consumers (justify)
|
||||
- [ ] minor — fixing revealed a missing capability that had to be added (justify)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT (the bug) / WHERE (files) / EVIDENCE (how it was confirmed). -->
|
||||
|
||||
**WHAT (bug):**
|
||||
**WHERE:**
|
||||
**EVIDENCE:**
|
||||
|
||||
## Pipeline requirements
|
||||
<!-- Every fix must clear these before the issue closes. -->
|
||||
- [ ] TDD: regression test written first, red → green
|
||||
- [ ] `ruff check` and `ruff format --check` clean
|
||||
- [ ] Spec-compliance review gate: PASS
|
||||
- [ ] Code-quality review gate: Approved
|
||||
- [ ] Full orchestration suite regression green (suite total)
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit references
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
- [ ] **Trigger** — what event activates it
|
||||
- [ ] **Read/write authority** — which canonical state it reads/changes
|
||||
- [ ] **Provenance** — derived state traceable to evidence
|
||||
- [ ] **Verification** — how we know the change helped
|
||||
- [ ] **Failure visibility** — how failure surfaces
|
||||
- [ ] **Reversibility** — bad learned state can be superseded/replayed/rolled back
|
||||
- [ ] **Dormant cost** — what it costs when irrelevant
|
||||
- [ ] **Benchmark + ablation** — what measurable behaviour improves; what happens when disabled
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · epistemic confidence ≠ attentional utility ≠ current applicability · retrieval never increases utility · self-model-induced behaviour is not independent evidence · historical truth survives present-state change · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: New feature or improvement — plain English first, verifiable close conditions, version intent
|
||||
title: "[FEATURE] "
|
||||
labels: feature
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## In plain English: <restate the issue title here in everyday words>
|
||||
<!-- The heading ITSELF must carry the summary — a scan of issue headings
|
||||
alone should tell the whole story of the board. TOP PRIORITY: if a
|
||||
technical detail below contradicts this section, the issue is buggy —
|
||||
fix the issue. A non-technical reader must be able to retell this
|
||||
section. 3-6 sentences, no jargon. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
What is this about: <what>
|
||||
|
||||
Why do we need it: <the itch — what happens today that shouldn't, or what becomes possible>
|
||||
|
||||
Why is it still open: <"not started yet" | "part done, X remains" | "deliberately parked as a future idea">
|
||||
|
||||
## Example
|
||||
<!-- A concrete before/after in plain terms: "If X happens, the system will Y instead of Z." -->
|
||||
|
||||
## What would let us close this
|
||||
<!-- Checkable statements, verifiable by looking at something that EXISTS —
|
||||
a file, a log line, a passing test. Not "when appropriate". -->
|
||||
- [ ] <condition 1 — observable>
|
||||
- [ ] <condition 2>
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
<!-- The pipeline derives the real bump from merged conventional commits and
|
||||
compares against this declaration. A mismatch is flagged, not fatal:
|
||||
either the declaration was wrong or the work grew. Both facts matter. -->
|
||||
- [ ] major — breaking: changes a contract, schema, or interface others depend on
|
||||
- [ ] minor — new capability: something becomes possible that wasn't
|
||||
- [ ] patch — repair: same behaviour, done correctly
|
||||
- [ ] none — touches no released artifact (docs, memory, process notes)
|
||||
|
||||
## Technical details
|
||||
<!-- WHAT — one-line objective.
|
||||
WHY — design-spec reference (S#) or reason.
|
||||
FILES — affected components.
|
||||
ACCEPTANCE — frozen-test file + test count + canary name.
|
||||
DEPENDENCIES — parent epic + preceding stage/plan refs. -->
|
||||
|
||||
**WHAT:**
|
||||
**WHY:**
|
||||
**FILES:**
|
||||
**ACCEPTANCE:**
|
||||
**DEPENDENCIES:**
|
||||
|
||||
## Pipeline requirements
|
||||
<!-- Every stage must clear these before the issue closes. -->
|
||||
- [ ] TDD: frozen tests written first, red → green (N passed)
|
||||
- [ ] `ruff check` and `ruff format --check` clean
|
||||
- [ ] Spec-compliance review gate: PASS
|
||||
- [ ] Code-quality review gate: Approved
|
||||
- [ ] Full orchestration suite regression green (suite total)
|
||||
- [ ] Canary passes (name)
|
||||
- [ ] Closed with a properly formatted closing comment + commit/PR references
|
||||
|
||||
## Cognitive invariants (memory/cognition issues — both handoffs)
|
||||
<!-- Complete ONLY if this issue touches memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before creating modules)
|
||||
- [ ] **Trigger** — what event activates it
|
||||
- [ ] **Read/write authority** — which canonical state it reads/changes
|
||||
- [ ] **Provenance** — derived state traceable to evidence
|
||||
- [ ] **Verification** — how we know the change helped
|
||||
- [ ] **Failure visibility** — how failure surfaces
|
||||
- [ ] **Reversibility** — bad learned state can be superseded/replayed/rolled back
|
||||
- [ ] **Dormant cost** — what it costs when irrelevant
|
||||
- [ ] **Benchmark + ablation** — what measurable behaviour improves; what happens when disabled
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · epistemic confidence ≠ attentional utility ≠ current applicability · retrieval never increases utility · self-model-induced behaviour is not independent evidence · historical truth survives present-state change · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — breaks the spine / core correctness, or blocks other work
|
||||
- [ ] **P1 — high** — material user value or a real defect (money, correctness bugs, next-stage dependency)
|
||||
- [ ] **P2 — medium** — quality/consolidation improvement (check the fold-into mapping first)
|
||||
- [ ] **P3 — low** — measurement / benchmark / deferred policy
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: Research Submission
|
||||
about: Buy down uncertainty before building — question, falsifier, budget, durable findings
|
||||
title: "[RESEARCH] "
|
||||
labels: research
|
||||
assignees: conductor
|
||||
---
|
||||
|
||||
## In plain English: <restate the issue title here in everyday words>
|
||||
<!-- The heading ITSELF must carry the summary — a scan of issue headings
|
||||
alone should tell the whole story of the board. TOP PRIORITY: if a
|
||||
technical detail below contradicts this section, the issue is buggy —
|
||||
fix the issue. -->
|
||||
<!-- SUBMITTED_BODY -->
|
||||
|
||||
|
||||
The question: <what we're trying to learn, one sentence>
|
||||
|
||||
Why we're asking: <what decision this research will inform — a name, an issue
|
||||
number, or "exploratory: none yet">
|
||||
|
||||
What we believe today: <the current assumption, stated so it can be proven wrong>
|
||||
|
||||
## The shape of an answer
|
||||
- [ ] What evidence would settle the question: <observation, benchmark, or spike>
|
||||
- [ ] What evidence would KILL the current assumption (the falsifier): <be specific —
|
||||
this is stated BEFORE starting so the research cannot become a search for confirmation>
|
||||
- [ ] Budget: <time/effort cap — when research must stop and report back>
|
||||
|
||||
## What would let us close this
|
||||
- [ ] Findings written to a durable document: <path, e.g. research/issue-NNN/> —
|
||||
a comment thread is not a durable finding
|
||||
- [ ] The decision the research informs is recorded: made / deferred / changed
|
||||
- [ ] If a follow-up build issue was minted: <#N or "none needed">
|
||||
|
||||
## Version impact (declared at submission — verified at merge)
|
||||
- [ ] none — research changes no released artifact (usual)
|
||||
- [ ] minor/major — only if a code spike is being merged as part of this (justify)
|
||||
|
||||
## Technical detail
|
||||
<!-- Candidate approaches, prior art in the repo, related issues, constraints,
|
||||
rebuildable-projection or authority requirements if findings touch canonical state. -->
|
||||
|
||||
## Pipeline requirements
|
||||
- [ ] Findings committed under the stated durable path
|
||||
- [ ] Every citation verified against a real source (no fabricated references)
|
||||
- [ ] Closed with a properly formatted closing comment: findings summary + decision
|
||||
|
||||
## Cognitive invariants (memory/cognition research — both handoffs)
|
||||
<!-- Complete ONLY if findings touch memory, learning, or self-model state. -->
|
||||
- [ ] **Owner** — which existing subsystem owns this (audit before proposing modules)
|
||||
- [ ] **Read/write authority** — which canonical state is affected
|
||||
- [ ] **Provenance** — findings traceable to evidence
|
||||
- [ ] **Reversibility** — projections remain rebuildable; no unique truth moves into a derived index
|
||||
- [ ] **No duplicate authority** — why extend vs create
|
||||
|
||||
Hard invariants: derived cognition preserves evidentiary ancestry · retrieval never increases utility · raw evidence is never destroyed by promotion.
|
||||
|
||||
## Related umbrella
|
||||
- [ ] Memory
|
||||
- [ ] Governance
|
||||
- [ ] Identity
|
||||
- [ ] Growth
|
||||
- [ ] Perception
|
||||
- [ ] Infrastructure
|
||||
- [ ] Life (Nick's)
|
||||
- [ ] Custodian
|
||||
- [ ] Orchestration
|
||||
|
||||
## Priority (REQUIRED — the priority gate)
|
||||
<!-- Pick exactly one. An issue without a priority is not filed. -->
|
||||
- [ ] **P0 — critical** — blocks a decision that blocks other work
|
||||
- [ ] **P1 — high** — informs a next-stage dependency
|
||||
- [ ] **P2 — medium** — quality/consolidation understanding
|
||||
- [ ] **P3 — low** — watchlist / deferred / exploratory
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.66.23"
|
||||
__version__ = "0.87.0"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""behaviour_ledger.py — T19 §19F/19G/19I: the canonical Behaviour
|
||||
Validation Ledger.
|
||||
|
||||
PRIMARY UNIT: the VERSIONED BEHAVIOUR / TEST SPECIFICATION — not "a
|
||||
test run in some session". Automated verification and real-world/user
|
||||
confirmation are DISTINCT truths (rule 19F): tests green ≠ user-
|
||||
confirmed.
|
||||
|
||||
GENERATION-AWARE HISTORY (rule 19G, mandatory): when a dependency
|
||||
changes, affected behaviours become RETEST_REQUIRED while their prior
|
||||
confirmation history stays queryable — confidence does not silently
|
||||
carry forward, and it does not silently disappear either.
|
||||
|
||||
SELECTIVE INVALIDATION (rule 19I): only behaviours whose structural
|
||||
dependencies changed are invalidated; every invalidation carries a
|
||||
reason; unrelated hardened confirmations are preserved.
|
||||
|
||||
Storage: <project_store>/behaviour_ledger.json (versioned document).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
LEDGER_SCHEMA_VERSION = 1
|
||||
|
||||
# hardening lifecycle (rule 19G semantics)
|
||||
UNVALIDATED = "UNVALIDATED" # not yet validated
|
||||
IMPLEMENTED = "IMPLEMENTED" # implementation exists
|
||||
AUTO_VERIFIED = "AUTO_VERIFIED" # automated verification passed
|
||||
REAL_WORLD_PASSED = "REAL_WORLD_PASSED" # manual/real test passed
|
||||
TEST_FAILED = "TEST_FAILED" # test failed
|
||||
PARTIAL = "PARTIAL" # inconclusive
|
||||
USER_CONFIRMED = "USER_CONFIRMED" # hardened
|
||||
RETEST_REQUIRED = "RETEST_REQUIRED" # confidence invalidated
|
||||
ACCEPTED_LIMITATION = "ACCEPTED_LIMITATION" # explicitly approved gap
|
||||
NOT_APPLICABLE = "NOT_APPLICABLE"
|
||||
|
||||
STATES = (UNVALIDATED, IMPLEMENTED, AUTO_VERIFIED, REAL_WORLD_PASSED,
|
||||
TEST_FAILED, PARTIAL, USER_CONFIRMED, RETEST_REQUIRED,
|
||||
ACCEPTED_LIMITATION, NOT_APPLICABLE)
|
||||
|
||||
# states that represent STANDING CONFIDENCE (selective invalidation
|
||||
# may reopen exactly these; everything else is already open or n/a)
|
||||
CONFIRMED_STATES = (AUTO_VERIFIED, REAL_WORLD_PASSED, USER_CONFIRMED)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
class BehaviourLedger:
|
||||
def __init__(self, store_dir: Path | str):
|
||||
self.path = Path(store_dir) / "behaviour_ledger.json"
|
||||
self.doc = self._load()
|
||||
|
||||
def _load(self) -> dict:
|
||||
if self.path.is_file():
|
||||
import json
|
||||
return json.loads(self.path.read_text())
|
||||
return {"schema_version": LEDGER_SCHEMA_VERSION,
|
||||
"behaviours": {}, "updated_at": _now()}
|
||||
|
||||
def save(self) -> None:
|
||||
import json
|
||||
self.doc["updated_at"] = _now()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps(self.doc, indent=1, sort_keys=True))
|
||||
|
||||
# -- specification ------------------------------------------------------
|
||||
|
||||
def define(self, *, behaviour_id: str, owning_project: str,
|
||||
intended_behaviour: str, origin_intent: str = "",
|
||||
pass_criteria: str = "", fail_criteria: str = "",
|
||||
procedure: str = "",
|
||||
owner_component: str = "",
|
||||
dependencies: list[str] | None = None,
|
||||
automated_ref: str = "",
|
||||
environment: str = "") -> dict:
|
||||
bid = (behaviour_id if behaviour_id.startswith("behaviour:")
|
||||
else f"behaviour:{behaviour_id}")
|
||||
if bid in self.doc["behaviours"]:
|
||||
raise ValueError(f"behaviour exists: {bid}")
|
||||
rec = {
|
||||
"behaviour_id": bid,
|
||||
"owning_project": owning_project,
|
||||
"intended_behaviour": intended_behaviour,
|
||||
"origin_intent": origin_intent,
|
||||
"pass_criteria": pass_criteria,
|
||||
"fail_criteria": fail_criteria,
|
||||
"procedure": procedure,
|
||||
"owner_component": owner_component, # structural entity id
|
||||
"dependencies": list(dependencies or []), # structural ids
|
||||
"automated_ref": automated_ref,
|
||||
"environment": environment,
|
||||
"state": UNVALIDATED,
|
||||
"evidence": [], # [{at, generation, kind, detail}]
|
||||
"history": [], # every state transition, never lost
|
||||
"invalidations": [], # [{at, generation, because}]
|
||||
"limitations": [],
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self.doc["behaviours"][bid] = rec
|
||||
return rec
|
||||
|
||||
def get(self, behaviour_id: str) -> dict | None:
|
||||
return self.doc["behaviours"].get(
|
||||
behaviour_id if behaviour_id.startswith("behaviour:")
|
||||
else f"behaviour:{behaviour_id}")
|
||||
|
||||
# -- state transitions (all recorded in history — rule 19G) -------------
|
||||
|
||||
def _transition(self, rec: dict, new_state: str, *, kind: str,
|
||||
detail: str = "", generation: str = "") -> None:
|
||||
if new_state not in STATES:
|
||||
raise ValueError(f"unknown validation state {new_state!r}")
|
||||
rec["history"].append({
|
||||
"at": _now(), "from": rec["state"], "to": new_state,
|
||||
"kind": kind, "detail": detail[:200],
|
||||
"generation": generation})
|
||||
rec["state"] = new_state
|
||||
rec["updated_at"] = _now()
|
||||
|
||||
def record_auto_verification(self, behaviour_id: str, *,
|
||||
passed: bool, detail: str = "",
|
||||
generation: str = "") -> dict:
|
||||
"""Automated verification. GREEN NEVER IMPLIES USER_CONFIRMED
|
||||
(rule 19W-22): the ceiling here is AUTO_VERIFIED."""
|
||||
rec = self.get(behaviour_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown behaviour {behaviour_id!r}")
|
||||
rec["evidence"].append({"at": _now(), "kind": "automated",
|
||||
"passed": passed,
|
||||
"detail": detail[:200],
|
||||
"generation": generation})
|
||||
self._transition(rec, AUTO_VERIFIED if passed else TEST_FAILED,
|
||||
kind="automated_verification", detail=detail,
|
||||
generation=generation)
|
||||
return rec
|
||||
|
||||
def record_real_world(self, behaviour_id: str, *, passed: bool,
|
||||
detail: str = "", generation: str = "",
|
||||
confirmed_by: str = "user") -> dict:
|
||||
"""Real-world/manual test result (testing mode). USER_CONFIRMED
|
||||
requires an explicit confirmed_by='user' pass (rule 19M)."""
|
||||
rec = self.get(behaviour_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown behaviour {behaviour_id!r}")
|
||||
rec["evidence"].append({"at": _now(), "kind": "real_world",
|
||||
"passed": passed,
|
||||
"detail": detail[:200],
|
||||
"generation": generation,
|
||||
"by": confirmed_by})
|
||||
if passed:
|
||||
state = (USER_CONFIRMED if confirmed_by == "user"
|
||||
else REAL_WORLD_PASSED)
|
||||
else:
|
||||
state = TEST_FAILED
|
||||
self._transition(rec, state, kind="real_world", detail=detail,
|
||||
generation=generation)
|
||||
return rec
|
||||
|
||||
def accept_limitation(self, behaviour_id: str, *, note: str,
|
||||
approved_by: str = "user") -> dict:
|
||||
rec = self.get(behaviour_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown behaviour {behaviour_id!r}")
|
||||
rec["limitations"].append({"at": _now(), "note": note[:200],
|
||||
"approved_by": approved_by})
|
||||
self._transition(rec, ACCEPTED_LIMITATION, kind="accept_limitation",
|
||||
detail=note, )
|
||||
return rec
|
||||
|
||||
# -- selective invalidation (rule 19I) -----------------------------------
|
||||
|
||||
def invalidate_for_change(self, *, changed_entities: set[str],
|
||||
generation: str = "",
|
||||
reasons: dict[str, str] | None = None
|
||||
) -> list[dict]:
|
||||
"""Mark RETEST_REQUIRED exactly those confirmed behaviours whose
|
||||
structural dependencies intersect the changed closure. Every
|
||||
invalidation carries a reason; unrelated confirmations stay.
|
||||
Returns the invalidated records."""
|
||||
reasons = reasons or {}
|
||||
invalidated = []
|
||||
for rec in self.doc["behaviours"].values():
|
||||
if rec["state"] not in CONFIRMED_STATES:
|
||||
continue
|
||||
overlap = set(rec.get("dependencies") or []) & changed_entities
|
||||
if rec.get("owner_component") in changed_entities:
|
||||
overlap.add(rec["owner_component"])
|
||||
if not overlap:
|
||||
continue # unrelated hardened behaviour: PRESERVED (19S-5)
|
||||
because = reasons.get(
|
||||
rec["behaviour_id"],
|
||||
f"structural change touched: {sorted(overlap)}")
|
||||
rec["invalidations"].append({
|
||||
"at": _now(), "generation": generation,
|
||||
"because": because[:200],
|
||||
"was": rec["state"],
|
||||
"changed_entities": sorted(overlap)})
|
||||
# HISTORY IS PRESERVED — prior confirmation stays queryable
|
||||
self._transition(rec, RETEST_REQUIRED,
|
||||
kind="selective_invalidation",
|
||||
detail=f"was {rec['state']}; {because}",
|
||||
generation=generation)
|
||||
invalidated.append(rec)
|
||||
return invalidated
|
||||
|
||||
def retest_history(self, behaviour_id: str) -> list[dict]:
|
||||
"""Generation-aware history: all confirmations + invalidations
|
||||
for one behaviour, oldest first (rule 19S-12)."""
|
||||
rec = self.get(behaviour_id)
|
||||
if rec is None:
|
||||
return []
|
||||
events = []
|
||||
for h in rec["history"]:
|
||||
events.append({"at": h["at"], "generation": h.get("generation"),
|
||||
"event": f"{h['from']}→{h['to']}",
|
||||
"kind": h["kind"], "detail": h.get("detail", "")})
|
||||
return events
|
||||
|
||||
# -- queue (testing mode, 19L/M) ------------------------------------------
|
||||
|
||||
def outstanding(self, *, component: str | None = None,
|
||||
project: str | None = None) -> list[dict]:
|
||||
"""Eligible testing items: anything not confirmed/accepted/n.a.
|
||||
Dynamic — the queue re-derives from states after every fix
|
||||
(rule 19M)."""
|
||||
out = []
|
||||
for rec in self.doc["behaviours"].values():
|
||||
if rec["state"] in (USER_CONFIRMED, ACCEPTED_LIMITATION,
|
||||
NOT_APPLICABLE):
|
||||
continue
|
||||
if project and rec.get("owning_project") != project:
|
||||
continue
|
||||
if component and rec.get("owner_component") != component:
|
||||
continue
|
||||
out.append(rec)
|
||||
out.sort(key=lambda r: (0 if r["state"] == TEST_FAILED else 1,
|
||||
r["behaviour_id"]))
|
||||
return out
|
||||
|
||||
def summary(self) -> dict:
|
||||
counts: dict[str, int] = {}
|
||||
for rec in self.doc["behaviours"].values():
|
||||
counts[rec["state"]] = counts.get(rec["state"], 0) + 1
|
||||
return {"total": len(self.doc["behaviours"]),
|
||||
"states": counts,
|
||||
"outstanding": len(self.outstanding())}
|
||||
@@ -0,0 +1,314 @@
|
||||
"""build_basis.py — T18 §18C: durable versioned RESEARCH BASIS for
|
||||
construction (V1 rule F: RESEARCH INFORMS STRUCTURE).
|
||||
|
||||
Research output must not disappear in transient model context. A Build
|
||||
Basis is a durable, schema-versioned document that captures WHAT
|
||||
research decided and WHY, with references — not research dumps. It is
|
||||
created after the research-depth decision and before/with the
|
||||
Construction Contract, and is attachable to:
|
||||
|
||||
- a Project Scaffold (project-level basis)
|
||||
- a Construction Contract (per-contract basis, via evidence_ref)
|
||||
- future structural entities where useful
|
||||
|
||||
Research Core ownership (rule C): this module NEVER researches. It
|
||||
records the depth decision (derived FROM adaptive_router's route — no
|
||||
second router), references the canonical pipeline's qualified output
|
||||
(stages ISSUE→…→QUALIFY, artifacts in research/issue-<N>/), and
|
||||
structures the adopt/adapt/combine/reject/innovate decisions (rule E).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# Research depth tiers — derived from adaptive_router ROUTES, never a
|
||||
# second router (rule C/A). Each tier records WHY it was chosen.
|
||||
DEPTH_NONE = "NONE" # tiny obvious local change; local evidence only
|
||||
DEPTH_INWARD = "INWARD" # repo-internal understanding suffices
|
||||
DEPTH_OUTWARD = "OUTWARD" # precedent/prior-art discovery required
|
||||
DEPTH_FULL = "FULL" # full pipeline incl. adversarial+synthesis
|
||||
|
||||
DEPTH_TIERS = (DEPTH_NONE, DEPTH_INWARD, DEPTH_OUTWARD, DEPTH_FULL)
|
||||
|
||||
# route → default depth (recorded as decision evidence; caller may
|
||||
# justify an exception, which is recorded as such — never silent)
|
||||
_ROUTE_DEFAULT_DEPTH = {
|
||||
"LOCAL_FIX": DEPTH_NONE,
|
||||
"STANDARD_CHANGE": DEPTH_INWARD,
|
||||
"RESEARCHED_CHANGE": DEPTH_FULL,
|
||||
}
|
||||
|
||||
|
||||
def research_depth(route: str, *, mode: str = "",
|
||||
justification: str = "") -> dict:
|
||||
"""The research-depth DECISION for a construction need. Derived from
|
||||
the canonical route authority (adaptive_router); `justification` is
|
||||
REQUIRED when deviating from the route default — recorded, never
|
||||
silent (rule D: record why research depth was chosen)."""
|
||||
default = _ROUTE_DEFAULT_DEPTH.get(route, DEPTH_INWARD)
|
||||
depth = default
|
||||
why = f"route {route} default"
|
||||
if justification and justification.strip():
|
||||
# an explicit justification selects a tier by name if given;
|
||||
# otherwise it annotates the default
|
||||
candidate = justification.strip().split(":", 1)[0].strip()
|
||||
if candidate in DEPTH_TIERS:
|
||||
depth = candidate
|
||||
why = justification.strip()
|
||||
else:
|
||||
why = f"route {route} default; note: {justification.strip()}"
|
||||
elif route not in _ROUTE_DEFAULT_DEPTH:
|
||||
why = f"route {route!r} unknown — INWARD floor (never silent NONE)"
|
||||
depth = DEPTH_INWARD
|
||||
return {"depth": depth, "why": why, "route": route, "mode": mode,
|
||||
"decided_at": _now()}
|
||||
|
||||
|
||||
# OUTWARD precedent-first ordering contract (rule E): the questions the
|
||||
# prior-art pass MUST answer before deeper material is consulted.
|
||||
OUTWARD_PRIOR_ART_QUESTIONS = (
|
||||
"what already exists addressing this intent",
|
||||
"what open-source systems/products/workflows attempt it",
|
||||
"what architectural patterns already work",
|
||||
"what standards/conventions exist",
|
||||
"what can be directly ADOPTED",
|
||||
"what can be ADAPTED",
|
||||
"what can be COMBINED",
|
||||
"what should be REJECTED",
|
||||
"where is genuine INNOVATION required",
|
||||
)
|
||||
|
||||
_DISPOSITIONS = ("ADOPT", "ADAPT", "COMBINE", "REJECT", "INNOVATE")
|
||||
|
||||
|
||||
def new_basis(*, question: str, depth_decision: dict,
|
||||
issue_ref: str = "") -> dict:
|
||||
"""Create an empty-but-valid Build Basis. References only — full
|
||||
research dumps live in the Research Core's artifact dirs (rule 18C:
|
||||
do not store unnecessary dumps)."""
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"kind": "build_basis",
|
||||
"created_at": _now(),
|
||||
"question": question,
|
||||
"issue_ref": issue_ref, # e.g. research/issue-164 or Gitea issue
|
||||
"depth": depth_decision,
|
||||
"inward": {"findings": [], "refs": []},
|
||||
"outward": {"prior_art_questions_answered": [],
|
||||
"findings": [], "refs": []},
|
||||
"deeper": {"used": False, "findings": [], "refs": []},
|
||||
"dispositions": [], # [{item, disposition: ADOPT/…, why, ref}]
|
||||
"alternatives_considered": [],
|
||||
"uncertainty": [],
|
||||
"risks": [],
|
||||
"implications": {"architecture": [], "structural": [],
|
||||
"migration": [], "verification": []},
|
||||
}
|
||||
|
||||
|
||||
def set_dispositions(basis: dict, dispositions: list[dict]) -> list[str]:
|
||||
"""Record the adopt/adapt/combine/reject/innovate decisions (rule E).
|
||||
Returns validation errors; the basis stays unqualified until valid."""
|
||||
basis["dispositions"] = [
|
||||
{"item": d.get("item", ""), "disposition": d.get("disposition", ""),
|
||||
"why": d.get("why", ""), "ref": d.get("ref", "")}
|
||||
for d in dispositions
|
||||
]
|
||||
return validate_basis(basis)
|
||||
|
||||
|
||||
def validate_basis(basis: dict) -> list[str]:
|
||||
"""Qualification gate. A basis is QUALIFIED when it carries a depth
|
||||
decision with why, at least one outward prior-art answer when depth
|
||||
is OUTWARD/FULL, and every disposition uses a legal disposition."""
|
||||
errors: list[str] = []
|
||||
if basis.get("schema_version") != SCHEMA_VERSION:
|
||||
errors.append(f"schema_version {basis.get('schema_version')!r}")
|
||||
depth = (basis.get("depth") or {}).get("depth")
|
||||
if depth not in DEPTH_TIERS:
|
||||
errors.append(f"depth {depth!r} not in {DEPTH_TIERS}")
|
||||
if not (basis.get("depth") or {}).get("why"):
|
||||
errors.append("depth decision lacks WHY (rule D)")
|
||||
if depth in (DEPTH_OUTWARD, DEPTH_FULL):
|
||||
outward = basis.get("outward") or {}
|
||||
if not (outward.get("prior_art_questions_answered")
|
||||
or outward.get("findings") or outward.get("refs")):
|
||||
errors.append("OUTWARD/FULL depth requires prior-art evidence")
|
||||
if depth == DEPTH_INWARD:
|
||||
inward = basis.get("inward") or {}
|
||||
if not (inward.get("findings") or inward.get("refs")
|
||||
or basis.get("dispositions")):
|
||||
errors.append("INWARD depth requires recorded evidence "
|
||||
"(findings/refs) — an empty basis is not "
|
||||
"qualified")
|
||||
for d in basis.get("dispositions", []):
|
||||
if d.get("disposition") not in _DISPOSITIONS:
|
||||
errors.append(f"illegal disposition {d.get('disposition')!r} "
|
||||
f"for {d.get('item','')[:40]!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def is_qualified(basis: dict) -> bool:
|
||||
return not validate_basis(basis)
|
||||
|
||||
|
||||
def attach_evidence_ref(basis: dict) -> str:
|
||||
"""The evidence_ref string a contract carries to point at this basis
|
||||
(durable pointer, not a copy — stores stay independent)."""
|
||||
return (f"build_basis:{basis.get('issue_ref') or 'adhoc'}:"
|
||||
f"{basis.get('created_at')}")
|
||||
|
||||
|
||||
def summary(basis: dict) -> dict:
|
||||
"""Compact projection for prompts/previews (no private reasoning —
|
||||
structured conclusions only, rule 18G)."""
|
||||
return {
|
||||
"schema_version": basis.get("schema_version"),
|
||||
"question": basis.get("question"),
|
||||
"issue_ref": basis.get("issue_ref"),
|
||||
"depth": basis.get("depth"),
|
||||
"qualified": is_qualified(basis),
|
||||
"validation": validate_basis(basis),
|
||||
"dispositions": [
|
||||
{"item": d.get("item", "")[:80],
|
||||
"disposition": d.get("disposition")}
|
||||
for d in basis.get("dispositions", [])
|
||||
],
|
||||
"uncertainty_count": len(basis.get("uncertainty", [])),
|
||||
"risks_count": len(basis.get("risks", [])),
|
||||
"implications": {k: len(v) for k, v in
|
||||
(basis.get("implications") or {}).items()},
|
||||
}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def revise_depth(basis: dict, *, new_depth: str, reason: str) -> dict:
|
||||
"""§21.1G: record a justified depth revision. Original + revised
|
||||
depths both persist. An OUTWARD→INWARD/NONE downgrade requires
|
||||
explicit resolution evidence ('discovery resolved', 'prior-art
|
||||
resolved', 'unnecessary') — never a bare skip."""
|
||||
if new_depth not in DEPTH_TIERS:
|
||||
raise ValueError(f"unknown depth {new_depth!r}")
|
||||
original = basis["depth"]["depth"]
|
||||
if original == new_depth:
|
||||
raise ValueError("no-op revision: depth unchanged")
|
||||
consequential_origins = ("RESEARCHED_CHANGE", "FULL", "OUTWARD")
|
||||
if original in consequential_origins and new_depth in ("NONE", "INWARD"):
|
||||
keywords = ("resolved", "non-material", "discovery",
|
||||
"unnecessary", "prior-art question")
|
||||
if not any(k in reason.lower() for k in keywords):
|
||||
raise ValueError(
|
||||
"OUTWARD downgrade requires explicit evidence: the "
|
||||
"prior-art question must be resolved as non-material "
|
||||
"(not merely skipped)")
|
||||
basis["depth_revision"] = {
|
||||
"original_depth": original, "revised_depth": new_depth,
|
||||
"reason": reason[:200], "revised_at": _now()}
|
||||
basis["depth"]["depth"] = new_depth
|
||||
basis["depth"]["why"] = f"revised from {original}: {reason}"
|
||||
return basis["depth_revision"]
|
||||
|
||||
|
||||
def to_json(basis: dict) -> str:
|
||||
import json
|
||||
return json.dumps(basis, indent=1, sort_keys=True)
|
||||
|
||||
|
||||
def from_json(text: str) -> dict:
|
||||
import json
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
_ = Any # typing import kept for forward annotations
|
||||
|
||||
|
||||
# ── T18 §18D CORRECTION (Nick, 2026-09-15): THE RESEARCH PLAN ITSELF IS
|
||||
# CONTEXTUALLY GENERATED. The prior-art categories researched for the
|
||||
# structural-model task (architecture graphs, monorepo tooling, etc.)
|
||||
# were CONTEXTUAL to that task — they must never become a fixed topic
|
||||
# list. For every request the plan is derived from:
|
||||
# intent → target/current reality → material unknowns/decisions/risks
|
||||
# → context-specific research topics → proportional depth.
|
||||
# There is NO permanent research-topic checklist anywhere in Scaffolding.
|
||||
|
||||
def generate_research_plan(*, intent: str, target: str = "",
|
||||
current_reality: str = "",
|
||||
unknowns: list[str] | None = None,
|
||||
decisions: list[str] | None = None,
|
||||
risks: list[str] | None = None,
|
||||
depth: str | None = None) -> dict:
|
||||
"""Generate a CONTEXT-SPECIFIC research plan for one construction
|
||||
intent. Topics are synthesized from the material unknowns/decisions/
|
||||
risks of THIS intent — the function has no topic vocabulary and no
|
||||
permanent category list. Depth, when not supplied, is chosen by
|
||||
consequence heuristics recorded in the plan (why is always present)."""
|
||||
unknowns = [u for u in (unknowns or []) if u.strip()]
|
||||
decisions = [d for d in (decisions or []) if d.strip()]
|
||||
risks = [r for r in (risks or []) if r.strip()]
|
||||
topics: list[dict] = []
|
||||
# each material unknown becomes a research topic aimed at resolving it
|
||||
for u in unknowns:
|
||||
topics.append({
|
||||
"topic": f"resolve unknown: {u}",
|
||||
"kind": "unknown-resolution",
|
||||
"why": f"material unknown blocks confident construction: {u[:80]}",
|
||||
})
|
||||
for d in decisions:
|
||||
topics.append({
|
||||
"topic": f"inform decision: {d}",
|
||||
"kind": "decision-support",
|
||||
"why": f"consequential decision needs precedent/evidence: {d[:80]}",
|
||||
})
|
||||
for r in risks:
|
||||
topics.append({
|
||||
"topic": f"de-risk: {r}",
|
||||
"kind": "risk-investigation",
|
||||
"why": f"identified risk needs verification/mitigation evidence: {r[:80]}",
|
||||
})
|
||||
# precedent question (rule E) attaches ONLY when outward evidence is
|
||||
# plausibly relevant — an unknown/decision about external prior art.
|
||||
needs_outward = any(
|
||||
w in " ".join(unknowns + decisions).lower()
|
||||
for w in ("exist", "existing", "prior art", "precedent", "standard",
|
||||
"convention", "library", "tool", "framework", "approach"))
|
||||
if needs_outward:
|
||||
topics.append({
|
||||
"topic": ("what already exists that is relevant to THIS intent "
|
||||
"(adopt/adapt/combine/reject/innovate)"),
|
||||
"kind": "prior-art",
|
||||
"why": "material unknown references existing practice — "
|
||||
"precedent discovery required before deeper research",
|
||||
})
|
||||
if depth is None:
|
||||
# consequence heuristics: novel/architectural/unsafe → deeper
|
||||
consequential = any(
|
||||
w in (intent + " " + " ".join(risks)).lower()
|
||||
for w in ("architecture", "migration", "replace", "rewrite",
|
||||
"security", "schema", "delete", "deploy", "breaking"))
|
||||
depth = DEPTH_FULL if (consequential and topics) else (
|
||||
DEPTH_OUTWARD if needs_outward and topics else (
|
||||
DEPTH_INWARD if topics else DEPTH_NONE))
|
||||
why = ("consequence heuristics: consequential keywords + research "
|
||||
"topics present" if depth == DEPTH_FULL else
|
||||
"precedent-relevant unknowns present" if depth == DEPTH_OUTWARD
|
||||
else "local understanding suffices" if depth == DEPTH_INWARD
|
||||
else "no material unknowns — local evidence only")
|
||||
else:
|
||||
why = "depth supplied by caller (recorded, caller accountable)"
|
||||
return {
|
||||
"intent": intent,
|
||||
"target": target,
|
||||
"current_reality_note": current_reality[:200],
|
||||
"depth": {"depth": depth, "why": why},
|
||||
"topics": topics,
|
||||
"plan_generated_at": _now(),
|
||||
# the plan ITSELF is evidence that research was contextual, not
|
||||
# checklist-driven: two different intents must produce disjoint
|
||||
# topic sets unless they share genuine unknowns
|
||||
}
|
||||
@@ -231,7 +231,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.66.23"' in rune_version,
|
||||
'__version__ = "0.87.0"' 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,405 @@
|
||||
"""capability_ledger.py — T19 §19D: compact versioned Capability
|
||||
Ledgers — local re-entry context packets.
|
||||
|
||||
PURPOSE (rule 19D): a fresh LLM rehydrates the meaning of a target area
|
||||
after compaction/context loss/model swap/long absence WITHOUT rereading
|
||||
the repository. Not a second source tree, not a second Scaffold, not
|
||||
prose dumps — structured, proportional, operationally-used records.
|
||||
|
||||
Identity rule: capability IDs are STABLE across file moves (name-based,
|
||||
not path-based); history is preserved on supersede/retire (rule 19Q).
|
||||
|
||||
Storage: <project_store>/capability_ledger.json — versioned document,
|
||||
provenance-aware (rule G). Historical versions remain traceable via
|
||||
the history list.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
LEDGER_SCHEMA_VERSION = 1
|
||||
|
||||
MATURITY = ("proposed", "implemented", "verified", "hardened",
|
||||
"retest_required", "retired", "superseded")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
class CapabilityLedger:
|
||||
def __init__(self, store_dir: Path | str):
|
||||
self.path = Path(store_dir) / "capability_ledger.json"
|
||||
self.doc = self._load()
|
||||
|
||||
def _load(self) -> dict:
|
||||
if self.path.is_file():
|
||||
import json
|
||||
return json.loads(self.path.read_text())
|
||||
return {"schema_version": LEDGER_SCHEMA_VERSION,
|
||||
"capabilities": {}, "history": [],
|
||||
"updated_at": _now()}
|
||||
|
||||
def save(self) -> None:
|
||||
import json
|
||||
self.doc["updated_at"] = _now()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps(self.doc, indent=1, sort_keys=True))
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
|
||||
def define(self, *, capability_id: str, name: str, purpose: str,
|
||||
owning_project: str, component: str,
|
||||
source_locations: list[str] | None = None,
|
||||
contract: str = "", state_touched: str = "",
|
||||
dependencies: list[str] | None = None,
|
||||
consumers: list[str] | None = None,
|
||||
invariants: list[str] | None = None,
|
||||
behaviour_specs: list[str] | None = None,
|
||||
constraints: str = "",
|
||||
generation: str = "") -> dict:
|
||||
"""Create a capability record (auto-invoked by Scaffolding when a
|
||||
meaningful capability is created — rule 19Q)."""
|
||||
cid = f"capability:{capability_id}"
|
||||
if cid in self.doc["capabilities"]:
|
||||
raise ValueError(f"capability exists: {cid}")
|
||||
rec = {
|
||||
"capability_id": cid,
|
||||
"name": name,
|
||||
"purpose": purpose,
|
||||
"owning_project": owning_project,
|
||||
"component": component, # structural entity id
|
||||
"source_locations": list(source_locations or []),
|
||||
"contract": contract,
|
||||
"state_touched": state_touched,
|
||||
"dependencies": list(dependencies or []),
|
||||
"consumers": list(consumers or []),
|
||||
"invariants": list(invariants or []),
|
||||
"behaviour_specs": list(behaviour_specs or []),
|
||||
"maturity": "implemented",
|
||||
"constraints": constraints,
|
||||
"generation": generation,
|
||||
"limitations": [],
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self.doc["capabilities"][cid] = rec
|
||||
return rec
|
||||
|
||||
def get(self, capability_id: str) -> dict | None:
|
||||
return self.doc["capabilities"].get(
|
||||
capability_id if ":" in capability_id
|
||||
else f"capability:{capability_id}")
|
||||
|
||||
def material_change(self, capability_id: str, *, note: str,
|
||||
generation: str = "",
|
||||
source_locations: list[str] | None = None) -> dict:
|
||||
"""Record a material change (auto-invoked after construction —
|
||||
rule 19Q.2). History entry keeps traceability; dependents' harden
|
||||
state is the behaviour ledger's job (via structural closure)."""
|
||||
rec = self.get(capability_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown capability {capability_id!r}")
|
||||
# T21.1 §21.1D: deterministic revision counter. Wall-clock
|
||||
# timestamps are metadata, not causal ordering (same-second events
|
||||
# share a stamp — the T21 race). The revision counter is the
|
||||
# canonical drift signal: material_revision > last governed
|
||||
# spec_revision = SPEC_VERSION_DRIFT.
|
||||
rev = int(rec.get("material_revision", 0)) + 1
|
||||
rec["material_revision"] = rev
|
||||
hist = {"at": _now(), "event": "material_change",
|
||||
"note": note[:200], "generation": generation,
|
||||
"prior_maturity": rec["maturity"],
|
||||
"material_revision": rev}
|
||||
self.doc["history"].append({"capability_id": rec["capability_id"],
|
||||
**hist})
|
||||
rec["updated_at"] = _now()
|
||||
if generation:
|
||||
rec["generation"] = generation
|
||||
if source_locations is not None:
|
||||
rec["source_locations"] = list(source_locations)
|
||||
return rec
|
||||
|
||||
def move(self, capability_id: str, new_locations: list[str]) -> dict:
|
||||
"""File move: STABLE IDENTITY SURVIVES (rule 19S-6); the move is
|
||||
recorded, history preserved."""
|
||||
rec = self.get(capability_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown capability {capability_id!r}")
|
||||
old = list(rec["source_locations"])
|
||||
rec["source_locations"] = list(new_locations)
|
||||
rec["updated_at"] = _now()
|
||||
self.doc["history"].append({
|
||||
"capability_id": rec["capability_id"], "at": _now(),
|
||||
"event": "moved", "from": old, "to": list(new_locations)})
|
||||
return rec
|
||||
|
||||
def supersede(self, capability_id: str, by: str, *,
|
||||
note: str = "") -> dict:
|
||||
rec = self.get(capability_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown capability {capability_id!r}")
|
||||
rec["maturity"] = "superseded"
|
||||
rec["superseded_by"] = (by if ":" in by else f"capability:{by}")
|
||||
rec["updated_at"] = _now()
|
||||
self.doc["history"].append({
|
||||
"capability_id": rec["capability_id"], "at": _now(),
|
||||
"event": "superseded", "by": rec["superseded_by"],
|
||||
"note": note[:160]})
|
||||
return rec
|
||||
|
||||
def retire(self, capability_id: str, *, note: str = "") -> dict:
|
||||
rec = self.get(capability_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown capability {capability_id!r}")
|
||||
rec["maturity"] = "retired"
|
||||
rec["updated_at"] = _now()
|
||||
self.doc["history"].append({
|
||||
"capability_id": rec["capability_id"], "at": _now(),
|
||||
"event": "retired", "note": note[:160]})
|
||||
return rec
|
||||
|
||||
# -- queries (surgical context, 19E) -----------------------------------
|
||||
|
||||
def active(self) -> list[dict]:
|
||||
return [r for r in self.doc["capabilities"].values()
|
||||
if r["maturity"] not in ("retired", "superseded")]
|
||||
|
||||
def by_component(self, component: str) -> list[dict]:
|
||||
return [r for r in self.active() if r["component"] == component]
|
||||
|
||||
def context_packet(self, capability_id: str) -> dict:
|
||||
"""The LOCAL context packet (19E): everything a fresh builder
|
||||
needs for one capability — bounded, structured, no reasoning."""
|
||||
rec = self.get(capability_id)
|
||||
if rec is None:
|
||||
return {"error": f"unknown capability {capability_id!r}"}
|
||||
return {
|
||||
"schema_version": LEDGER_SCHEMA_VERSION,
|
||||
"capability_id": rec["capability_id"],
|
||||
"name": rec["name"],
|
||||
"purpose": rec["purpose"],
|
||||
"component": rec["component"],
|
||||
"source_locations": rec["source_locations"],
|
||||
"contract": rec["contract"],
|
||||
"state_touched": rec["state_touched"],
|
||||
"dependencies": rec["dependencies"],
|
||||
"consumers": rec["consumers"],
|
||||
"invariants": rec["invariants"],
|
||||
"behaviour_specs": rec["behaviour_specs"],
|
||||
"maturity": rec["maturity"],
|
||||
"constraints": rec["constraints"],
|
||||
"generation": rec["generation"],
|
||||
"limitations": rec["limitations"],
|
||||
}
|
||||
|
||||
|
||||
# ── T21 §21C/21D: LIVING CAPABILITY SPECIFICATIONS + STRICT SEMVER ──────
|
||||
#
|
||||
# A Living Capability Spec is the WHY+WHAT-SHOULD-EXIST layer; the
|
||||
# capability record remains the WHAT-EXISTS+WHERE layer. Both live in the
|
||||
# SAME ledger document (rule C: no second authority) — the spec is a
|
||||
# versioned field-set on the capability, not a separate subsystem.
|
||||
#
|
||||
# STRICT SEMVER (§21D): only meaningful units get versions.
|
||||
# PATCH: bug fix / internal change, contract compatible
|
||||
# MINOR: backwards-compatible new capability/behaviour
|
||||
# MAJOR: breaking contract/interface/state-semantics change
|
||||
# Baselines for existing capabilities: explicit
|
||||
# governance_version_started_at + baseline_reason; no fake history.
|
||||
|
||||
SPEC_SCHEMA_VERSION = 1
|
||||
|
||||
import re as _re
|
||||
|
||||
SEMVER_RE = _re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
||||
|
||||
|
||||
class SpecError(ValueError):
|
||||
"""Invalid spec operation (material change without bump, unjustified
|
||||
bump, bad version...)."""
|
||||
|
||||
|
||||
def classify_version_bump(*, contract_changed: bool,
|
||||
behaviour_added: bool,
|
||||
consumer_break: bool,
|
||||
implementation_only: bool) -> str:
|
||||
"""§21D-3: derive PATCH/MINOR/MAJOR from the change shape. The model
|
||||
may propose; the machinery verifies."""
|
||||
if consumer_break:
|
||||
return "MAJOR"
|
||||
if contract_changed:
|
||||
# contract changed but no consumer break: interface tightened —
|
||||
# still breaking for consumers relying on the old shape
|
||||
return "MAJOR"
|
||||
if behaviour_added:
|
||||
return "MINOR"
|
||||
if implementation_only:
|
||||
return "PATCH"
|
||||
raise SpecError("no version-worthy change: metadata churn does not bump")
|
||||
|
||||
|
||||
def bump_version(version: str, kind: str) -> str:
|
||||
m = SEMVER_RE.match(version)
|
||||
if not m:
|
||||
raise SpecError(f"invalid semver {version!r}")
|
||||
major, minor, patch = (int(x) for x in m.groups())
|
||||
if kind == "MAJOR":
|
||||
return f"{major + 1}.0.0"
|
||||
if kind == "MINOR":
|
||||
return f"{major}.{minor + 1}.0"
|
||||
if kind == "PATCH":
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
raise SpecError(f"unknown bump kind {kind!r}")
|
||||
|
||||
|
||||
def attach_spec(ledger: "CapabilityLedger", capability_id: str, *,
|
||||
requirements: list[str],
|
||||
expected_behaviours: list[str],
|
||||
non_goals: list[str],
|
||||
acceptance_criteria: list[str],
|
||||
baseline_version: str = "0.1.0",
|
||||
baseline_reason: str = "initial governed baseline",
|
||||
governance_version_started_at: str | None = None) -> dict:
|
||||
"""Attach a Living Capability Specification to an existing capability
|
||||
record (rule 21C: extend, don't duplicate). Baseline policy (§21D-1):
|
||||
the starting version is explicit — 1.0.0 only for stable contracts
|
||||
with sufficient evidence; otherwise 0.x with a reason."""
|
||||
from rune.capability_ledger import LEDGER_SCHEMA_VERSION # noqa: F401
|
||||
rec = ledger.get(capability_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown capability {capability_id!r}")
|
||||
if not SEMVER_RE.match(baseline_version):
|
||||
raise SpecError(f"invalid baseline semver {baseline_version!r}")
|
||||
spec = {
|
||||
"schema_version": SPEC_SCHEMA_VERSION,
|
||||
"version": baseline_version,
|
||||
"governance_version_started_at": governance_version_started_at
|
||||
or _now(),
|
||||
"baseline_reason": baseline_reason,
|
||||
"requirements": requirements,
|
||||
"expected_behaviours": expected_behaviours,
|
||||
"non_goals": non_goals,
|
||||
"acceptance_criteria": acceptance_criteria,
|
||||
"version_history": [{
|
||||
"at": _now(), "version": baseline_version, "kind": "BASELINE",
|
||||
"reason": baseline_reason, "changed_sections": [],
|
||||
"affected_consumers": [], "validation_consequences": [],
|
||||
}],
|
||||
}
|
||||
rec["spec"] = spec
|
||||
rec["updated_at"] = _now()
|
||||
return spec
|
||||
|
||||
|
||||
def revise_spec(ledger: "CapabilityLedger", capability_id: str, *,
|
||||
contract_changed: bool = False,
|
||||
behaviour_added: bool = False,
|
||||
consumer_break: bool = False,
|
||||
implementation_only: bool = False,
|
||||
reason: str,
|
||||
changed_sections: list[str] | None = None,
|
||||
affected_consumers: list[str] | None = None,
|
||||
validation_consequences: list[str] | None = None,
|
||||
proposed_kind: str | None = None) -> dict:
|
||||
"""§21D-3/21E: revise a spec with an AUTOMATIC version decision.
|
||||
The model proposes; the machinery classifies and records. Material
|
||||
change without a bump is impossible (every revise computes one);
|
||||
unjustified bumps are impossible (no-change revise raises)."""
|
||||
rec = ledger.get(capability_id)
|
||||
if rec is None:
|
||||
raise ValueError(f"unknown capability {capability_id!r}")
|
||||
spec = rec.get("spec")
|
||||
if spec is None:
|
||||
raise SpecError(f"{capability_id} has no spec — attach one first")
|
||||
kind = (proposed_kind or classify_version_bump(
|
||||
contract_changed=contract_changed,
|
||||
behaviour_added=behaviour_added,
|
||||
consumer_break=consumer_break,
|
||||
implementation_only=implementation_only))
|
||||
# verify a PROPOSED kind against the change shape (model cannot
|
||||
# upgrade PATCH to MINOR without the corresponding change shape)
|
||||
actual = classify_version_bump(
|
||||
contract_changed=contract_changed,
|
||||
behaviour_added=behaviour_added,
|
||||
consumer_break=consumer_break,
|
||||
implementation_only=implementation_only)
|
||||
order = {"PATCH": 0, "MINOR": 1, "MAJOR": 2}
|
||||
if proposed_kind and order[proposed_kind] > order[actual]:
|
||||
raise SpecError(
|
||||
f"proposed {proposed_kind} exceeds change shape {actual} — "
|
||||
f"unjustified bump")
|
||||
old = spec["version"]
|
||||
new = bump_version(old, kind)
|
||||
spec["version"] = new
|
||||
spec["version_history"].append({
|
||||
"at": _now(), "version": new, "kind": kind,
|
||||
"reason": reason[:200],
|
||||
"changed_sections": list(changed_sections or []),
|
||||
"affected_consumers": list(affected_consumers or []),
|
||||
"validation_consequences": list(validation_consequences or []),
|
||||
# T21.1 §21.1D: the governed decision COVERS all material
|
||||
# revisions up to and including the current one — drift closes
|
||||
# deterministically, not by timestamp comparison
|
||||
"covers_material_revision": int(rec.get("material_revision", 0)),
|
||||
})
|
||||
rec["updated_at"] = _now()
|
||||
return {"old": old, "new": new, "kind": kind, "reason": reason}
|
||||
|
||||
|
||||
def spec_drift_status(ledger: "CapabilityLedger",
|
||||
capability_id: str) -> dict:
|
||||
"""T21.1 §21.1D: the callable drift detector / consistency gate.
|
||||
Deterministic and revision-based (never wall-clock):
|
||||
|
||||
material_revision > last governed covers_material_revision
|
||||
→ SPEC_VERSION_DRIFT + durable obligation
|
||||
|
||||
Returns {"drift": bool, "obligation": str|None, "detail": ...}."""
|
||||
rec = ledger.get(capability_id)
|
||||
if rec is None:
|
||||
return {"drift": False, "obligation": None,
|
||||
"detail": f"unknown capability {capability_id!r}"}
|
||||
spec = rec.get("spec")
|
||||
if spec is None:
|
||||
return {"drift": False, "obligation": None,
|
||||
"detail": "no spec attached — nothing to drift from"}
|
||||
hist = spec.get("version_history") or []
|
||||
covered = hist[-1].get("covers_material_revision", 0) if hist else 0
|
||||
current = int(rec.get("material_revision", 0))
|
||||
if current > covered:
|
||||
return {
|
||||
"drift": True,
|
||||
"obligation": (
|
||||
f"SPEC_VERSION_DRIFT: {capability_id} has "
|
||||
f"{current - covered} uncovered material revision(s) "
|
||||
f"(material rev {current} > governed rev {covered}) — "
|
||||
f"run revise_spec (PATCH/MINOR/MAJOR) to close"),
|
||||
"detail": {"material_revision": current,
|
||||
"governed_revision": covered,
|
||||
"spec_version": spec.get("version")},
|
||||
}
|
||||
return {"drift": False, "obligation": None,
|
||||
"detail": {"material_revision": current,
|
||||
"governed_revision": covered,
|
||||
"spec_version": spec.get("version")}}
|
||||
|
||||
|
||||
def spec_context(rec: dict) -> dict:
|
||||
"""The spec half of a surgical context packet (§21M): WHY + WHAT
|
||||
SHOULD EXIST, compact."""
|
||||
s = rec.get("spec") or {}
|
||||
return {
|
||||
"capability_id": rec["capability_id"],
|
||||
"version": s.get("version"),
|
||||
"purpose": rec.get("purpose"),
|
||||
"requirements": s.get("requirements", []),
|
||||
"expected_behaviours": s.get("expected_behaviours", []),
|
||||
"non_goals": s.get("non_goals", []),
|
||||
"acceptance_criteria": s.get("acceptance_criteria", []),
|
||||
"contract": rec.get("contract"),
|
||||
"invariants": rec.get("invariants", []),
|
||||
"behaviour_specs": rec.get("behaviour_specs", []),
|
||||
"last_bump": (s.get("version_history") or [{}])[-1],
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""complexity_scorer.py — Auto-detection of task complexity for build mode.
|
||||
|
||||
Scores user requests to determine if they need:
|
||||
- Task mode (0-3): direct execution
|
||||
- Task mode with planning (4-7): add planning step
|
||||
- Build mode (8+): full pipeline
|
||||
|
||||
Research basis:
|
||||
- No existing tool does auto-detection (SWE-agent, Aider, Devin, Cursor, OpenCode)
|
||||
- Simple keyword scoring: zero cost for 90% of requests, LLM fallback for 10%
|
||||
- Agent reliability degrades 96% → 34% from 8K to 128K tokens (LOCA-bench, ICML 2026)
|
||||
- Single-file changes: ~80% success vs <50% for 4-6 files (SWE-bench)
|
||||
|
||||
Usage:
|
||||
complexity_scorer.py score "add user auth with JWT, update API routes, add tests"
|
||||
complexity_scorer.py mode "fix the typo in README"
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# SIGNAL DEFINITIONS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Request analysis signals (regex, zero cost)
|
||||
CONJUNCTIONS = ["and", "then", "also", "additionally", "after that", "plus", "as well as"]
|
||||
CROSS_SYSTEM_REFS = [
|
||||
"api",
|
||||
"frontend",
|
||||
"backend",
|
||||
"database",
|
||||
"tests",
|
||||
"config",
|
||||
"ci",
|
||||
"deploy",
|
||||
"ui",
|
||||
"cli",
|
||||
"server",
|
||||
"client",
|
||||
"model",
|
||||
"view",
|
||||
]
|
||||
ARCH_KEYWORDS = [
|
||||
"refactor",
|
||||
"migrate",
|
||||
"redesign",
|
||||
"pipeline",
|
||||
"system",
|
||||
"architecture",
|
||||
"overhaul",
|
||||
"restructure",
|
||||
"reorganize",
|
||||
"consolidate",
|
||||
]
|
||||
NEW_ABSTRACTION_KEYWORDS = [
|
||||
"create a new",
|
||||
"build a new",
|
||||
"implement a new",
|
||||
"add a new",
|
||||
"new module",
|
||||
"new system",
|
||||
"new feature",
|
||||
"new component",
|
||||
"new service",
|
||||
"new layer",
|
||||
]
|
||||
SCOPE_MODIFIERS = ["entire", "all", "every", "throughout", "comprehensive", "complete", "full", "whole", "across"]
|
||||
EXT_DEPENDENCY_KEYWORDS = [
|
||||
"integrate",
|
||||
"third-party",
|
||||
"library",
|
||||
"package",
|
||||
"sdk",
|
||||
"api",
|
||||
"service",
|
||||
"webhook",
|
||||
"external",
|
||||
]
|
||||
COMPLEXITY_MARKERS = [
|
||||
"complex",
|
||||
"big change",
|
||||
"major",
|
||||
"significant",
|
||||
"extensive",
|
||||
"substantial",
|
||||
"non-trivial",
|
||||
"involved",
|
||||
]
|
||||
SIMPLE_FIX_INDICATORS = [
|
||||
"fix typo",
|
||||
"rename",
|
||||
"update comment",
|
||||
"change color",
|
||||
"add import",
|
||||
"remove import",
|
||||
"fix whitespace",
|
||||
"format",
|
||||
"lint",
|
||||
"bump version",
|
||||
"update readme",
|
||||
"fix comment",
|
||||
]
|
||||
|
||||
# Build mode trigger keywords (high confidence)
|
||||
BUILD_TRIGGERS = [
|
||||
"implement",
|
||||
"build",
|
||||
"create feature",
|
||||
"refactor",
|
||||
"migrate",
|
||||
"across multiple files",
|
||||
"new system",
|
||||
"new module",
|
||||
"overhaul",
|
||||
"redesign",
|
||||
"restructure",
|
||||
"full pipeline",
|
||||
"end-to-end",
|
||||
]
|
||||
|
||||
# Task mode trigger keywords
|
||||
TASK_TRIGGERS = [
|
||||
"fix",
|
||||
"change",
|
||||
"update",
|
||||
"rename",
|
||||
"bug",
|
||||
"typo",
|
||||
"error",
|
||||
"crash",
|
||||
"broken",
|
||||
"not working",
|
||||
"issue",
|
||||
"patch",
|
||||
"hotfix",
|
||||
]
|
||||
|
||||
# Research mode trigger keywords
|
||||
RESEARCH_TRIGGERS = [
|
||||
"how does",
|
||||
"what is",
|
||||
"explain",
|
||||
"investigate",
|
||||
"explore",
|
||||
"research",
|
||||
"understand",
|
||||
"analyze",
|
||||
"study",
|
||||
"review",
|
||||
]
|
||||
|
||||
# Plan mode trigger keywords
|
||||
PLAN_TRIGGERS = [
|
||||
"plan",
|
||||
"design",
|
||||
"architecture",
|
||||
"before we start",
|
||||
"approach",
|
||||
"strategy",
|
||||
"roadmap",
|
||||
"blueprint",
|
||||
"spec",
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# SCORING FUNCTIONS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoreBreakdown:
|
||||
"""Detailed breakdown of complexity score."""
|
||||
|
||||
request_analysis: int = 0
|
||||
codebase_impact: int = 0
|
||||
historical_patterns: int = 0
|
||||
risk_assessment: int = 0
|
||||
total: int = 0
|
||||
signals: List[str] = field(default_factory=list)
|
||||
mode: str = "task"
|
||||
confidence: str = "high" # high, medium, low
|
||||
|
||||
|
||||
def analyze_request_text(request: str) -> tuple[int, List[str]]:
|
||||
"""Analyze request text for complexity signals. Zero cost, <1ms."""
|
||||
score = 0
|
||||
signals = []
|
||||
request_lower = request.lower()
|
||||
|
||||
# Multiple verbs/conjunctions
|
||||
conjunction_matches = [c for c in CONJUNCTIONS if c in request_lower]
|
||||
if len(conjunction_matches) >= 2:
|
||||
score += 2
|
||||
signals.append(f"Multiple conjunctions: {', '.join(conjunction_matches)}")
|
||||
elif len(conjunction_matches) == 1:
|
||||
score += 1
|
||||
signals.append(f"Conjunction: {conjunction_matches[0]}")
|
||||
|
||||
# Cross-system references
|
||||
system_matches = [s for s in CROSS_SYSTEM_REFS if s in request_lower]
|
||||
if len(system_matches) >= 2:
|
||||
score += 3
|
||||
signals.append(f"Cross-system: {', '.join(system_matches)}")
|
||||
elif len(system_matches) == 1:
|
||||
score += 1
|
||||
signals.append(f"System ref: {system_matches[0]}")
|
||||
|
||||
# Architecture keywords
|
||||
arch_matches = [k for k in ARCH_KEYWORDS if k in request_lower]
|
||||
if arch_matches:
|
||||
score += 2
|
||||
signals.append(f"Architecture: {', '.join(arch_matches)}")
|
||||
|
||||
# New abstraction keywords
|
||||
new_matches = [k for k in NEW_ABSTRACTION_KEYWORDS if k in request_lower]
|
||||
if new_matches:
|
||||
score += 2
|
||||
signals.append(f"New abstraction: {', '.join(new_matches)}")
|
||||
|
||||
# Scope modifiers
|
||||
scope_matches = [s for s in SCOPE_MODIFIERS if s in request_lower]
|
||||
if scope_matches:
|
||||
score += 1
|
||||
signals.append(f"Scope: {', '.join(scope_matches)}")
|
||||
|
||||
# External dependency mentions
|
||||
ext_matches = [k for k in EXT_DEPENDENCY_KEYWORDS if k in request_lower]
|
||||
if ext_matches:
|
||||
score += 1
|
||||
signals.append(f"External dep: {', '.join(ext_matches)}")
|
||||
|
||||
# Explicit complexity markers
|
||||
complex_matches = [k for k in COMPLEXITY_MARKERS if k in request_lower]
|
||||
if complex_matches:
|
||||
score += 3
|
||||
signals.append(f"Complexity marker: {', '.join(complex_matches)}")
|
||||
|
||||
# Simple fix indicators (negative score)
|
||||
simple_matches = [k for k in SIMPLE_FIX_INDICATORS if k in request_lower]
|
||||
if simple_matches:
|
||||
score -= 3
|
||||
signals.append(f"Simple fix: {', '.join(simple_matches)}")
|
||||
|
||||
return score, signals
|
||||
|
||||
|
||||
def estimate_codebase_impact(request: str, repo_path: str = "/mnt/workdrive/rune") -> tuple[int, List[str]]:
|
||||
"""Estimate codebase impact using grep/glob. ~100ms."""
|
||||
score = 0
|
||||
signals = []
|
||||
|
||||
# Extract potential module/function names from request
|
||||
words = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", request)
|
||||
# Filter out common words
|
||||
stop_words = {
|
||||
"the",
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"to",
|
||||
"for",
|
||||
"of",
|
||||
"with",
|
||||
"by",
|
||||
"from",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"have",
|
||||
"has",
|
||||
"had",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"will",
|
||||
"would",
|
||||
"could",
|
||||
"should",
|
||||
"may",
|
||||
"might",
|
||||
"can",
|
||||
"shall",
|
||||
"that",
|
||||
"which",
|
||||
"who",
|
||||
"whom",
|
||||
"this",
|
||||
"these",
|
||||
"those",
|
||||
"it",
|
||||
"its",
|
||||
"my",
|
||||
"your",
|
||||
"his",
|
||||
"her",
|
||||
"our",
|
||||
"their",
|
||||
"me",
|
||||
"you",
|
||||
"him",
|
||||
"us",
|
||||
"them",
|
||||
"i",
|
||||
"we",
|
||||
"he",
|
||||
"she",
|
||||
"they",
|
||||
"what",
|
||||
"where",
|
||||
"when",
|
||||
"how",
|
||||
"why",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"so",
|
||||
"just",
|
||||
"also",
|
||||
"very",
|
||||
"too",
|
||||
"much",
|
||||
"many",
|
||||
"some",
|
||||
"any",
|
||||
"all",
|
||||
"each",
|
||||
"every",
|
||||
"both",
|
||||
"few",
|
||||
"more",
|
||||
"most",
|
||||
"other",
|
||||
"another",
|
||||
"such",
|
||||
"no",
|
||||
"not",
|
||||
"only",
|
||||
"own",
|
||||
"same",
|
||||
"than",
|
||||
"too",
|
||||
"now",
|
||||
"here",
|
||||
"there",
|
||||
"about",
|
||||
"up",
|
||||
"out",
|
||||
"off",
|
||||
"over",
|
||||
"under",
|
||||
"again",
|
||||
"further",
|
||||
"then",
|
||||
"once",
|
||||
"new",
|
||||
"add",
|
||||
"update",
|
||||
"fix",
|
||||
"change",
|
||||
"make",
|
||||
"create",
|
||||
"build",
|
||||
"implement",
|
||||
}
|
||||
potential_modules = [w for w in words if w.lower() not in stop_words and len(w) > 3]
|
||||
|
||||
# Check if any potential modules exist in codebase
|
||||
if potential_modules:
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
# Quick grep for potential module names
|
||||
grep_pattern = "|".join(potential_modules[:5]) # Limit to 5 to avoid slow grep
|
||||
result = subprocess.run(
|
||||
["grep", "-r", "-l", "-E", grep_pattern, os.path.join(repo_path, "src")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
files = [f for f in result.stdout.strip().split("\n") if f]
|
||||
if len(files) > 5:
|
||||
score += 3
|
||||
signals.append(f"Files likely affected: {len(files)}")
|
||||
elif len(files) > 2:
|
||||
score += 1
|
||||
signals.append(f"Files possibly affected: {len(files)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check for cross-module dependencies
|
||||
if "src/rune" in repo_path:
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["grep", "-r", "-c", "import", os.path.join(repo_path, "src/rune")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
import_count = sum(
|
||||
int(line.split(":")[-1])
|
||||
for line in result.stdout.strip().split("\n")
|
||||
if ":" in line and line.split(":")[-1].strip().isdigit()
|
||||
)
|
||||
if import_count > 50:
|
||||
score += 2
|
||||
signals.append(f"High import density: {import_count} imports")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return score, signals
|
||||
|
||||
|
||||
def check_historical_patterns(request: str) -> tuple[int, List[str]]:
|
||||
"""Check memory for similar past tasks. Returns score and signals."""
|
||||
score = 0
|
||||
signals = []
|
||||
|
||||
# Check if similar tasks needed build mode
|
||||
try:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.expanduser("~/.config/opencode/scripts"))
|
||||
import memory_store
|
||||
|
||||
db = memory_store.connect()
|
||||
try:
|
||||
results = memory_store.cached_recall(db, f"build mode {request}", budget=3)
|
||||
if results:
|
||||
for r in results:
|
||||
text = r.get("text", "").lower()
|
||||
if "build mode" in text or "multi-step" in text or "complex" in text:
|
||||
score += 2
|
||||
signals.append("Similar past task needed build mode")
|
||||
break
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return score, signals
|
||||
|
||||
|
||||
def assess_risk(request: str, repo_path: str = "/mnt/workdrive/rune") -> tuple[int, List[str]]:
|
||||
"""Assess risk based on file paths and content."""
|
||||
score = 0
|
||||
signals = []
|
||||
request_lower = request.lower()
|
||||
|
||||
# Auth/security code
|
||||
auth_keywords = ["auth", "security", "token", "credential", "password", "login", "session"]
|
||||
if any(k in request_lower for k in auth_keywords):
|
||||
score += 2
|
||||
signals.append("Touches auth/security code")
|
||||
|
||||
# Data layer
|
||||
data_keywords = ["database", "migration", "schema", "sql", "sqlite", "model", "orm"]
|
||||
if any(k in request_lower for k in data_keywords):
|
||||
score += 1
|
||||
signals.append("Touches data layer")
|
||||
|
||||
# Public API
|
||||
api_keywords = ["api", "endpoint", "route", "export", "interface", "sdk", "public"]
|
||||
if any(k in request_lower for k in api_keywords):
|
||||
score += 2
|
||||
signals.append("Touches public API")
|
||||
|
||||
# Reversibility concern
|
||||
destructive_keywords = ["delete", "remove", "drop", "destroy", "clear", "reset", "wipe"]
|
||||
if any(k in request_lower for k in destructive_keywords):
|
||||
score += 1
|
||||
signals.append("Reversibility concern")
|
||||
|
||||
return score, signals
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# MAIN SCORING
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def score_request(request: str, repo_path: str = "/mnt/workdrive/rune") -> ScoreBreakdown:
|
||||
"""Score a request for complexity. Returns ScoreBreakdown with total and mode."""
|
||||
breakdown = ScoreBreakdown()
|
||||
|
||||
# Signal Category 1: Request Analysis (zero cost, <1ms)
|
||||
req_score, req_signals = analyze_request_text(request)
|
||||
breakdown.request_analysis = req_score
|
||||
breakdown.signals.extend(req_signals)
|
||||
|
||||
# Signal Category 2: Codebase Impact (~100ms)
|
||||
impact_score, impact_signals = estimate_codebase_impact(request, repo_path)
|
||||
breakdown.codebase_impact = impact_score
|
||||
breakdown.signals.extend(impact_signals)
|
||||
|
||||
# Signal Category 3: Historical Patterns
|
||||
hist_score, hist_signals = check_historical_patterns(request)
|
||||
breakdown.historical_patterns = hist_score
|
||||
breakdown.signals.extend(hist_signals)
|
||||
|
||||
# Signal Category 4: Risk Assessment
|
||||
risk_score, risk_signals = assess_risk(request, repo_path)
|
||||
breakdown.risk_assessment = risk_score
|
||||
breakdown.signals.extend(risk_signals)
|
||||
|
||||
# Calculate total
|
||||
breakdown.total = (
|
||||
breakdown.request_analysis
|
||||
+ breakdown.codebase_impact
|
||||
+ breakdown.historical_patterns
|
||||
+ breakdown.risk_assessment
|
||||
)
|
||||
|
||||
# Determine mode
|
||||
if breakdown.total >= 8:
|
||||
breakdown.mode = "build"
|
||||
breakdown.confidence = "high"
|
||||
elif breakdown.total >= 4:
|
||||
breakdown.mode = "task_with_planning"
|
||||
breakdown.confidence = "medium"
|
||||
else:
|
||||
breakdown.mode = "task"
|
||||
breakdown.confidence = "high"
|
||||
|
||||
# Check for explicit mode triggers
|
||||
request_lower = request.lower()
|
||||
if any(t in request_lower for t in BUILD_TRIGGERS):
|
||||
if breakdown.mode != "build":
|
||||
breakdown.mode = "build"
|
||||
breakdown.confidence = "high"
|
||||
breakdown.signals.append("Explicit build trigger detected")
|
||||
elif any(t in request_lower for t in RESEARCH_TRIGGERS):
|
||||
breakdown.mode = "research"
|
||||
breakdown.confidence = "high"
|
||||
breakdown.signals.append("Research trigger detected")
|
||||
elif any(t in request_lower for t in PLAN_TRIGGERS):
|
||||
breakdown.mode = "plan"
|
||||
breakdown.confidence = "high"
|
||||
breakdown.signals.append("Plan trigger detected")
|
||||
|
||||
return breakdown
|
||||
|
||||
|
||||
def get_mode(request: str, repo_path: str = "/mnt/workdrive/rune") -> str:
|
||||
"""Get the recommended mode for a request. Returns mode string."""
|
||||
breakdown = score_request(request, repo_path)
|
||||
return breakdown.mode
|
||||
|
||||
|
||||
def get_score(request: str, repo_path: str = "/mnt/workdrive/rune") -> Dict[str, Any]:
|
||||
"""Get detailed score breakdown. Returns dict."""
|
||||
breakdown = score_request(request, repo_path)
|
||||
return asdict(breakdown)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: complexity_scorer.py [score|mode] <request>")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
request = " ".join(sys.argv[2:])
|
||||
|
||||
if cmd == "score":
|
||||
result = get_score(request)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
elif cmd == "mode":
|
||||
mode = get_mode(request)
|
||||
print(mode)
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,237 @@
|
||||
"""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}")
|
||||
awareness = _auto_structural_awareness(project, ps, scaffold)
|
||||
rec.update(
|
||||
{
|
||||
"activated": True,
|
||||
"lifecycle": "project_build",
|
||||
"project": project,
|
||||
"project_scaffold": str(ps.current),
|
||||
"structural_awareness": awareness,
|
||||
"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 _auto_structural_awareness(project: str, project_scaffold: sc.Scaffold,
|
||||
parent_scaffold: sc.Scaffold) -> dict:
|
||||
"""T21.5 §9 (rule 19Q family): governed adoption/construction expands
|
||||
Rune's persistent structural world-model AUTOMATICALLY — no manual
|
||||
Registry/Spine bookkeeping step. Boundaries preserved:
|
||||
- Registry = discovery/index (that the project exists, where its
|
||||
canonical Scaffold lives) — never project internals;
|
||||
- Spine = Rune's relationship TO the project (one edge, builds/
|
||||
manages vocabulary) — project modules stay in the Project
|
||||
Scaffold (never duplicated into Spine);
|
||||
- Project Scaffold remains canonical for the project's internals.
|
||||
Failure-tolerant: awareness must not break the bootstrap (recorded
|
||||
in the return, not raised)."""
|
||||
result: dict = {"registry": "skipped", "spine": "skipped"}
|
||||
try:
|
||||
from rune.project_registry import ProjectRegistry
|
||||
store_dir = parent_scaffold.current.parent
|
||||
registry = ProjectRegistry(store_dir)
|
||||
if registry.get(project) is None:
|
||||
registry.register(
|
||||
project_id=project,
|
||||
scaffold_location=str(project_scaffold.current.parent),
|
||||
repo_identity="", # undecided at bootstrap (identity
|
||||
# before repository — never invented)
|
||||
)
|
||||
registry.save()
|
||||
result["registry"] = "registered"
|
||||
except (OSError, ValueError) as exc:
|
||||
result["registry"] = f"error: {exc!s:.120}"
|
||||
try:
|
||||
from rune import spine as spine_mod
|
||||
spine_path = parent_scaffold.current.parent.parent / "spine" / "current.json"
|
||||
sdoc = spine_mod.load(spine_path)
|
||||
if sdoc is not None:
|
||||
spine_mod.add_relationship(
|
||||
sdoc, type="manages",
|
||||
from_id="project:rune", to_id=f"project:{project}",
|
||||
evidence_refs=[f"project-build-bootstrap:{project}"])
|
||||
spine_mod.save(sdoc, spine_path)
|
||||
result["spine"] = "recorded"
|
||||
except (OSError, ValueError) as exc:
|
||||
result["spine"] = f"error: {exc!s:.120}"
|
||||
return result
|
||||
|
||||
|
||||
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_decision = trace.get("mode_decision")
|
||||
if mode_decision is None:
|
||||
# T21.5 fix: activate() expects the mode_decision object (with
|
||||
# .mode/.target_project/.evidence); the trace carries its dict
|
||||
# form, so reconstruct a ConstructionMode from it.
|
||||
from rune.construction_mode import ConstructionMode
|
||||
md = trace.get("construction_mode", {}) or {}
|
||||
if md.get("mode", "UNKNOWN") == "UNKNOWN":
|
||||
md = dict(md)
|
||||
md["mode"] = "UNKNOWN"
|
||||
mode_decision = ConstructionMode(
|
||||
mode=md.get("mode", "UNKNOWN"),
|
||||
evidence=list(md.get("evidence", [])),
|
||||
target_project=md.get("target_project"),
|
||||
project_authority=md.get("project_authority"),
|
||||
confidence=md.get("confidence", 0.0),
|
||||
clarification_required=md.get("clarification_required", False),
|
||||
probe_questions=list(md.get("probe_questions", [])),
|
||||
bootstrap_required=md.get("bootstrap_required", False),
|
||||
override=md.get("override"),
|
||||
proposed_by_model=md.get("proposed_by_model"),
|
||||
)
|
||||
return activate(mode_decision, 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,555 @@
|
||||
"""construction_intent.py — T17 §17J-§17N: the conversational
|
||||
interpretation layer of contextual construction.
|
||||
|
||||
AUTHORITY BOUNDARY (§17O):
|
||||
|
||||
THIS LAYER owns: meaning, referent resolution, target resolution,
|
||||
action-vs-discussion, conversational authorization, user constraints.
|
||||
|
||||
SCAFFOLDING (construction_mode.classify_mode) owns: construction
|
||||
classification, depth, contracts, scope, lifecycle.
|
||||
|
||||
This module NEVER deepens or reclassifies — it resolves what the user
|
||||
MEANT and whether they AUTHORIZED it, then hands a structured
|
||||
ConstructionIntent to Scaffolding, which runs the ONE canonical
|
||||
classifier (classify_mode). No second classifier lives here.
|
||||
|
||||
Not a phrase table: explicit demands are recognized by imperative
|
||||
grammar + construction verbs; questions are recognized by
|
||||
interrogative structure; referents are resolved against the live
|
||||
conversation context (pending proposals, recency, topic drift).
|
||||
Paraphrases resolve because the criteria are structural, not lexical
|
||||
incantations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ── conversation context (the layer's memory, NOT Scaffolding state) ────
|
||||
|
||||
@dataclass
|
||||
class Proposal:
|
||||
"""A pending construction proposal awaiting user authorization.
|
||||
Rune suggesting a change is NOT authorization to mutate (§17L)."""
|
||||
proposal_id: str
|
||||
summary: str
|
||||
created_turn: int
|
||||
superseded: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationContext:
|
||||
"""Active conversational/project context for referent resolution.
|
||||
Recency-aware, proposal-aware, stale-context-resistant (§17M)."""
|
||||
turn: int = 0
|
||||
recent_turns: list = field(default_factory=list) # [{role, text}]
|
||||
pending_proposals: list = field(default_factory=list) # [Proposal]
|
||||
active_project: str | None = None
|
||||
active_task_id: str | None = None
|
||||
|
||||
def next_turn(self) -> int:
|
||||
self.turn += 1
|
||||
return self.turn
|
||||
|
||||
def record_turn(self, role: str, text: str) -> None:
|
||||
self.recent_turns.append({"role": role, "text": text,
|
||||
"turn": self.turn})
|
||||
self.turn += 1 # each recorded exchange advances the context
|
||||
|
||||
def add_proposal(self, summary: str) -> Proposal:
|
||||
p = Proposal(proposal_id=f"prop-{uuid.uuid4().hex[:8]}",
|
||||
summary=summary,
|
||||
created_turn=self.turn)
|
||||
self.pending_proposals.append(p)
|
||||
return p
|
||||
|
||||
def supersede_proposal(self, proposal_id: str) -> None:
|
||||
for p in self.pending_proposals:
|
||||
if p.proposal_id == proposal_id:
|
||||
p.superseded = True
|
||||
|
||||
def live_proposals(self) -> list:
|
||||
return [p for p in self.pending_proposals if not p.superseded]
|
||||
|
||||
|
||||
# ── the structured intent (§17N) ─────────────────────────────────────────
|
||||
|
||||
APPROVAL_STATES = ("DISCUSSION", "PROPOSAL", "USER_APPROVED",
|
||||
"CONSTRUCTION_STARTED")
|
||||
|
||||
# approval-state lifecycle legality (§17L): authorization may only be
|
||||
# granted from PROPOSAL or DISCUSSION (direct demand); CONSTRUCTION_STARTED
|
||||
# and mutations are Scaffolding's side of the boundary.
|
||||
LEGAL_APPROVAL_TRANSITIONS = {
|
||||
"DISCUSSION": {"PROPOSAL", "USER_APPROVED"},
|
||||
"PROPOSAL": {"USER_APPROVED"},
|
||||
"USER_APPROVED": {"CONSTRUCTION_STARTED"},
|
||||
"CONSTRUCTION_STARTED": set(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConstructionIntent:
|
||||
"""Structured meaning-resolution output (§17N). Scaffolding decides
|
||||
mode/depth from this; this layer never does."""
|
||||
intent_id: str
|
||||
objective: str
|
||||
requested_effect: str
|
||||
resolved_target: str | None
|
||||
resolved_referent: str | None
|
||||
origin_context_id: str | None
|
||||
action_intent: str # ACTION | DISCUSSION
|
||||
approval_state: str # DISCUSSION | PROPOSAL | USER_APPROVED
|
||||
confidence: float
|
||||
ambiguity: str # NONE | REFERENT | TARGET | SCOPE
|
||||
consequence_level: str # LOW | MEDIUM | HIGH
|
||||
relevant_constraints: list = field(default_factory=list)
|
||||
classification_evidence: list = field(default_factory=list)
|
||||
probe_questions: list = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"intent_id": self.intent_id,
|
||||
"objective": self.objective,
|
||||
"requested_effect": self.requested_effect,
|
||||
"resolved_target": self.resolved_target,
|
||||
"resolved_referent": self.resolved_referent,
|
||||
"origin_context_id": self.origin_context_id,
|
||||
"action_intent": self.action_intent,
|
||||
"approval_state": self.approval_state,
|
||||
"confidence": self.confidence,
|
||||
"ambiguity": self.ambiguity,
|
||||
"consequence_level": self.consequence_level,
|
||||
"relevant_constraints": self.relevant_constraints,
|
||||
"classification_evidence": self.classification_evidence,
|
||||
"probe_questions": self.probe_questions,
|
||||
}
|
||||
|
||||
|
||||
# ── structural lexical criteria (grammar classes, not incantations) ─────
|
||||
|
||||
# §17M stale-context resistance: a proposal older than this many turns
|
||||
# is stale unless the user's text explicitly references its content.
|
||||
STALE_GAP_TURNS = 2
|
||||
|
||||
_NEW_ARTICLE = re.compile(r"\b(me a|me an|a|an|another)\b")
|
||||
_CREATION_VERBS = {"build", "create", "make", "write", "develop"}
|
||||
|
||||
_CONSTRUCTION_VERBS = {
|
||||
"build", "implement", "add", "replace", "fix", "create", "make",
|
||||
"write", "port", "migrate", "refactor", "integrate", "remove",
|
||||
"extend", "wire", "ship",
|
||||
}
|
||||
_DEMONSTRATIVES = {"that", "this", "it", "those", "these"}
|
||||
_OPTION_REFERENTS = re.compile(
|
||||
r"\b(first|second|third|other|latter|former)\s+(option|approach|one)\b")
|
||||
_QUESTION_MARKERS = re.compile(
|
||||
r"^\s*(how|what|why|when|where|who|which|should|could|would|can|"
|
||||
r"is|are|did|do you|do we|do they|does it|does this|does that)\b|"
|
||||
r"\?\s*$", re.IGNORECASE)
|
||||
_ANALYSIS_MARKERS = re.compile(
|
||||
r"\b(think|opinion|view|recommend|suggest|assess|evaluate|review|"
|
||||
r"compare|consider|worth it|worth it\?|needs? improvement|any "
|
||||
r"ideas?)\b", re.IGNORECASE)
|
||||
# relative-'that' detection by PRECEDING context (T18 18N fix): 'that'
|
||||
# is a relative pronoun when the word before it is an article/noun
|
||||
# ("the proposal that you suggested") — demonstrative when
|
||||
# sentence-initial or after a verb/punctuation ("build that" /
|
||||
# "that works for me"). The old following-word heuristic misclassified
|
||||
# "that works for me" as a relative clause.
|
||||
_ARTICLES = {"the", "a", "an", "this", "these", "those", "one"}
|
||||
|
||||
|
||||
def _is_relative_that(low: str, dem_tokens: set) -> bool:
|
||||
"""True when every 'that' token reads as a relative pronoun by its
|
||||
preceding context; demonstrative otherwise."""
|
||||
words = re.findall(r"[a-z']+", low)
|
||||
seen_that = False
|
||||
for i, w in enumerate(words):
|
||||
if w != "that":
|
||||
continue
|
||||
seen_that = True
|
||||
prev = words[i - 1] if i > 0 else None
|
||||
if prev is None or prev in _CONSTRUCTION_VERBS:
|
||||
return False # sentence-initial or post-verb → demonstrative
|
||||
if prev not in _ARTICLES:
|
||||
# preceding word is neither article nor verb: treat as
|
||||
# demonstrative unless it is clearly a noun modifier —
|
||||
# conservative default is demonstrative (referent-seeking)
|
||||
return False
|
||||
return seen_that and "that" in dem_tokens
|
||||
|
||||
_GO_AHEAD = re.compile(
|
||||
r"^\s*(yes|yeah|yep|ok|okay|sure|go ahead|please do|do it|do that|"
|
||||
r"carry on|proceed|approved|lgtm|ship it)\b[\s.,!]*$", re.IGNORECASE)
|
||||
_AFFIRMATION_OPENERS = re.compile(
|
||||
r"^\s*(yes|yeah|yep|yup|ok|okay|sure|agreed|absolutely|indeed|"
|
||||
r"alright|right)\b", re.IGNORECASE)
|
||||
_APPROVAL_IDIOM = re.compile(
|
||||
r"\b(go ahead|do it|do that|carry on|build that|implement that|"
|
||||
r"make it so|make it happen|make that happen|that's the one|go with|"
|
||||
r"let's go|let's do|let's have it|proceed|good with that|"
|
||||
r"fine with that|happy with that|on board|approved|ship it)\b",
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
return set(re.split(r"[^a-z0-9]+", text.lower()))
|
||||
|
||||
|
||||
_NEGATION = re.compile(
|
||||
r"^\s*(don't|do not|dont|never|not yet|stop|hold off|no need|"
|
||||
r"skip it|forget it)\b", re.IGNORECASE)
|
||||
_IDIOM_PREFIX = re.compile(
|
||||
r"^\s*(go ahead and|go and|please|kindly|then|now)\s+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_question(text: str) -> bool:
|
||||
"""Interrogative STRUCTURE, not vocabulary: leading wh/auxiliary or
|
||||
terminal '?'. 'How would you build this?' is a question regardless
|
||||
of the construction verb inside it (§17K)."""
|
||||
if "?" in text and not re.match(r"^\s*(build|implement|add|fix|make|"
|
||||
r"create|write|replace|remove)\b",
|
||||
text, re.IGNORECASE):
|
||||
return True
|
||||
return bool(_QUESTION_MARKERS.match(text))
|
||||
|
||||
|
||||
def _imperative_verb(text: str) -> str | None:
|
||||
"""Leading imperative construction verb — the strong/direct demand
|
||||
signal (§17K). 'Build me a program...' → 'build'. Idiom prefixes
|
||||
('go ahead and build X', 'please implement X') are stripped so the
|
||||
real verb surfaces."""
|
||||
t = text
|
||||
for _ in range(3):
|
||||
t2 = _IDIOM_PREFIX.sub("", t)
|
||||
if t2 == t:
|
||||
break
|
||||
t = t2
|
||||
m = re.match(r"^\s*(\w+)", t, re.IGNORECASE)
|
||||
if m and m.group(1).lower() in _CONSTRUCTION_VERBS:
|
||||
return m.group(1).lower()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_construction_intent(
|
||||
text: str,
|
||||
context: ConversationContext,
|
||||
*,
|
||||
origin_context_id: str | None = None,
|
||||
) -> ConstructionIntent:
|
||||
"""Resolve one user turn into a structured ConstructionIntent.
|
||||
|
||||
Criteria (§17J), in precedence order:
|
||||
1. interrogative structure → DISCUSSION (never mutation); analysis
|
||||
of an improvement question may RECORD a proposal for later.
|
||||
2. bare approval ("go ahead") or demonstrative authorization
|
||||
("build that") → resolve referent against LIVE proposals,
|
||||
recency-first; multiple equally plausible → clarification;
|
||||
none live → clarification (stale-context resistant).
|
||||
3. explicit imperative with resolved target → USER_APPROVED
|
||||
direct demand — no unnecessary re-asking (§17K).
|
||||
4. everything else → DISCUSSION; construction language without
|
||||
resolved target → ambiguity flagged, never guessed (§17M).
|
||||
"""
|
||||
context.next_turn()
|
||||
context.record_turn("user", text)
|
||||
t = text.strip()
|
||||
low = t.lower()
|
||||
ev: list[str] = []
|
||||
probes: list[str] = []
|
||||
|
||||
# 0. NEGATION (§18N): "don't build that yet" blocks authorization —
|
||||
# construction language inside a negation frame is NOT a demand.
|
||||
if _NEGATION.match(t):
|
||||
ev.append("negation frame — explicitly blocks construction")
|
||||
return _intent(t, "DISCUSSION", "DISCUSSION", 0.85, "NONE",
|
||||
ev, probes, context, origin_context_id)
|
||||
|
||||
# 1a. REQUEST MODALS: "can you fix that?" is an imperative in
|
||||
# interrogative clothing — a state-changing action request, NOT an
|
||||
# information question. Information openers (how/what/why/should we)
|
||||
# stay discussion; request modals authorize when the target resolves.
|
||||
# information openers outrank modals: "how would you build X?" asks
|
||||
# about METHOD; "can you fix that?" requests ACTION
|
||||
information_opener = bool(re.match(
|
||||
r"^\s*(how|what|why|when|where|which)\b", low))
|
||||
request_modal = (not information_opener) and bool(re.search(
|
||||
r"\b(can you|could you|would you|will you|please)\b", low))
|
||||
if request_modal:
|
||||
# modal + a construction verb somewhere in the turn
|
||||
request_modal = bool(_tokens(low) & _CONSTRUCTION_VERBS)
|
||||
if request_modal:
|
||||
ev.append("request modal (can/could/would you, please) + "
|
||||
"construction verb → action request, not discussion")
|
||||
# a modal + demonstrative + live proposal authorizes THAT
|
||||
# proposal (referent resolution outranks target resolution)
|
||||
dem_tokens_m = _tokens(low) & _DEMONSTRATIVES
|
||||
if dem_tokens_m and context.live_proposals():
|
||||
referent = _resolve_referent(low, context)
|
||||
if referent and not referent.get("ambiguous"):
|
||||
tp = referent["proposal"]
|
||||
ev.append(f"modal authorization resolves proposal "
|
||||
f"{tp.proposal_id}")
|
||||
return ConstructionIntent(
|
||||
intent_id=f"intent-{uuid.uuid4().hex[:8]}",
|
||||
objective=tp.summary,
|
||||
requested_effect=f"construct: {tp.summary}",
|
||||
resolved_target=context.active_project,
|
||||
resolved_referent=tp.proposal_id,
|
||||
origin_context_id=origin_context_id,
|
||||
action_intent="ACTION", approval_state="USER_APPROVED",
|
||||
confidence=0.85, ambiguity="NONE",
|
||||
consequence_level=_consequence(t, context),
|
||||
relevant_constraints=_constraints(context),
|
||||
classification_evidence=ev)
|
||||
target = context.active_project
|
||||
if target:
|
||||
ev.append(f"target resolved from active context: {target}")
|
||||
# demonstrative referent: "fix that" with no live proposal —
|
||||
# the antecedent is the defect described in THIS turn
|
||||
return ConstructionIntent(
|
||||
intent_id=f"intent-{uuid.uuid4().hex[:8]}",
|
||||
objective=t, requested_effect=f"fix/act: {t}",
|
||||
resolved_target=target,
|
||||
resolved_referent="this-turn-topic",
|
||||
origin_context_id=origin_context_id,
|
||||
action_intent="ACTION", approval_state="USER_APPROVED",
|
||||
confidence=0.85, ambiguity="NONE",
|
||||
consequence_level=_consequence(t, context),
|
||||
relevant_constraints=_constraints(context),
|
||||
classification_evidence=ev)
|
||||
ev.append("request modal but no resolvable target")
|
||||
probes.append("where should that change land — which project?")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.5, "TARGET",
|
||||
ev, probes, context, origin_context_id)
|
||||
|
||||
# 1. questions are discussion — even with construction verbs inside
|
||||
if _is_question(t):
|
||||
ev.append("interrogative structure → discussion, not authorization")
|
||||
analysis = bool(_ANALYSIS_MARKERS.search(t))
|
||||
if analysis:
|
||||
ev.append("analysis/advice request — a proposal may be recorded "
|
||||
"for later authorization")
|
||||
return ConstructionIntent(
|
||||
intent_id=f"intent-{uuid.uuid4().hex[:8]}",
|
||||
objective=t, requested_effect="analysis/discussion",
|
||||
resolved_target=context.active_project,
|
||||
resolved_referent=None, origin_context_id=origin_context_id,
|
||||
action_intent="DISCUSSION", approval_state="DISCUSSION",
|
||||
confidence=0.85, ambiguity="NONE",
|
||||
consequence_level="LOW", classification_evidence=ev)
|
||||
|
||||
# 2. authorization of a prior proposal (bare approval or demonstrative)
|
||||
bare_approval = bool(_GO_AHEAD.match(t))
|
||||
imperative = _imperative_verb(t)
|
||||
dem_tokens = _tokens(low) & _DEMONSTRATIVES
|
||||
if "that" in dem_tokens and _is_relative_that(low, dem_tokens):
|
||||
dem_tokens.discard("that") # relative clause, not a demonstrative
|
||||
ev.append("'that' is a relative pronoun here, not a referent")
|
||||
demonstrative = bool(dem_tokens) or \
|
||||
bool(_OPTION_REFERENTS.search(low))
|
||||
option_ref = bool(_OPTION_REFERENTS.search(low))
|
||||
approval_idiom = bool(_APPROVAL_IDIOM.search(low))
|
||||
affirmation = bool(_AFFIRMATION_OPENERS.match(t))
|
||||
authorized = (bare_approval or approval_idiom or
|
||||
(affirmation and (demonstrative or option_ref)) or
|
||||
(imperative and (demonstrative or option_ref)) or
|
||||
(option_ref and imperative is not None))
|
||||
|
||||
if authorized:
|
||||
referent = _resolve_referent(low, context)
|
||||
if referent is None:
|
||||
# "go ahead and build X": idiom + explicit imperative — fall
|
||||
# through to the demand path (target resolution) rather than
|
||||
# dead-ending in referent ambiguity
|
||||
if imperative is None:
|
||||
ev.append("authorization language but NO live resolvable "
|
||||
"referent — never guessed")
|
||||
probes.append("what should that refer to? I don't have an "
|
||||
"active proposal matching it.")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.4, "REFERENT",
|
||||
ev, probes, context, origin_context_id)
|
||||
ev.append("no live proposal matches; treating as direct "
|
||||
"demand (explicit imperative object present)")
|
||||
if referent is None:
|
||||
# live proposals exist but are ALL STALE (topic moved on) —
|
||||
# never approve a stale referent; fall to the demand path
|
||||
ev.append("proposals stale for this turn — not selected")
|
||||
if imperative is None:
|
||||
probes.append("what should that refer to? The earlier "
|
||||
"proposal no longer seems active.")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.4, "REFERENT",
|
||||
ev, probes, context, origin_context_id)
|
||||
ev.append("stale proposals ignored; explicit imperative → "
|
||||
"demand path")
|
||||
referent = {"__fallthrough__": True}
|
||||
if referent.get("ambiguous"):
|
||||
ev.append("multiple equally plausible referents — asking, "
|
||||
"not guessing (§17M)")
|
||||
probes.append("which one — " + " or ".join(
|
||||
p.summary[:40] for p in referent["candidates"]) + "?")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.4, "REFERENT",
|
||||
ev, probes, context, origin_context_id)
|
||||
if referent.get("__fallthrough__"):
|
||||
# demand path: target from context or clarify (never stale)
|
||||
target = context.active_project
|
||||
if not target and imperative in _CREATION_VERBS and \
|
||||
_NEW_ARTICLE.search(low):
|
||||
# new standalone artifact demand — no target needed
|
||||
ev.append("creation verb + indefinite article → new "
|
||||
"standalone artifact; no existing target required")
|
||||
return _intent(t, "ACTION", "USER_APPROVED", 0.85, "NONE",
|
||||
ev, probes, context, origin_context_id,
|
||||
requested_effect=f"construct new: {t}",
|
||||
consequence_level=_consequence(t, context))
|
||||
if target:
|
||||
ev.append(f"target resolved from active context: {target}")
|
||||
return _intent(t, "ACTION", "USER_APPROVED", 0.8, "NONE",
|
||||
ev, probes, context, origin_context_id,
|
||||
requested_effect=f"construct: {t}",
|
||||
consequence_level=_consequence(t, context))
|
||||
probes.append("build where — which project/system?")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.5, "TARGET",
|
||||
ev, probes, context, origin_context_id)
|
||||
target_prop = referent["proposal"]
|
||||
ev.append(f"referent resolved: {target_prop.proposal_id} "
|
||||
f"(recency-first among live proposals)")
|
||||
ev.append("prior proposal + explicit authorization → USER_APPROVED")
|
||||
return ConstructionIntent(
|
||||
intent_id=f"intent-{uuid.uuid4().hex[:8]}",
|
||||
objective=target_prop.summary,
|
||||
requested_effect=f"construct: {target_prop.summary}",
|
||||
resolved_target=context.active_project,
|
||||
resolved_referent=target_prop.proposal_id,
|
||||
origin_context_id=origin_context_id,
|
||||
action_intent="ACTION", approval_state="USER_APPROVED",
|
||||
confidence=0.9, ambiguity="NONE",
|
||||
consequence_level=_consequence(t, context),
|
||||
relevant_constraints=_constraints(context),
|
||||
classification_evidence=ev)
|
||||
|
||||
# 3. explicit imperative construction demand with resolved target
|
||||
if imperative:
|
||||
# non-constructible object check is Scaffolding's classifier's
|
||||
# job (§17O) — this layer records the demand structure only
|
||||
ev.append(f"imperative construction verb: {imperative} — strong/"
|
||||
f"direct demand signal (§17K)")
|
||||
target = context.active_project
|
||||
if target:
|
||||
ev.append(f"target resolved from active context: {target}")
|
||||
return _intent(t, "ACTION", "USER_APPROVED", 0.9, "NONE",
|
||||
ev, probes, context, origin_context_id,
|
||||
requested_effect=f"construct: {t}",
|
||||
consequence_level=_consequence(t, context))
|
||||
# no active project: a CREATION verb + indefinite article signals
|
||||
# a NEW standalone artifact — the demand is complete without an
|
||||
# existing target ("build me a program that...")
|
||||
if imperative in _CREATION_VERBS and _NEW_ARTICLE.search(low):
|
||||
ev.append("creation verb + indefinite article → new standalone "
|
||||
"artifact demand; no existing target required")
|
||||
return _intent(t, "ACTION", "USER_APPROVED", 0.85, "NONE",
|
||||
ev, probes, context, origin_context_id,
|
||||
requested_effect=f"construct new: {t}")
|
||||
ev.append("imperative demand but no resolvable target — "
|
||||
"clarification, never guessing")
|
||||
probes.append("build where — which project/system?")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.5, "TARGET",
|
||||
ev, probes, context, origin_context_id)
|
||||
|
||||
# 4. default: discussion / context statement
|
||||
ev.append("no authorization structure, no imperative demand → "
|
||||
"discussion")
|
||||
return _intent(t, "DISCUSSION", "DISCUSSION", 0.7, "NONE",
|
||||
ev, probes, context, origin_context_id)
|
||||
|
||||
|
||||
def _resolve_referent(low: str, context: ConversationContext) -> dict | None:
|
||||
"""Recency-first referent resolution over LIVE proposals (§17M).
|
||||
Stale-context-resistant: proposals older than STALE_GAP_TURNS are
|
||||
ignored unless the user's text explicitly references their content.
|
||||
Returns {"proposal": Proposal} or {"ambiguous": True,
|
||||
"candidates": [...]} or None."""
|
||||
live = context.live_proposals()
|
||||
if not live:
|
||||
return None
|
||||
fresh = [p for p in live
|
||||
if context.turn - p.created_turn <= STALE_GAP_TURNS
|
||||
or _references(p.summary, low)]
|
||||
if not fresh:
|
||||
return None # all live proposals are stale for this turn
|
||||
live = fresh
|
||||
# option-selectors pick among live proposals by ordinal
|
||||
m = _OPTION_REFERENTS.search(low)
|
||||
if m:
|
||||
ordinals = {"first": 0, "second": 1, "third": 2, "other": 1,
|
||||
"latter": len(live) - 1, "former": 0}
|
||||
idx = ordinals.get(m.group(1).lower())
|
||||
if idx is not None and idx < len(live):
|
||||
return {"proposal": live[idx]}
|
||||
if len(live) > 1:
|
||||
return {"ambiguous": True, "candidates": live}
|
||||
# multiple live proposals + no ordinal → ambiguous unless recency
|
||||
# is decisive (only one proposal younger than the last topic shift)
|
||||
if len(live) > 1:
|
||||
# recency heuristic: if the newest proposal is at least 2 turns
|
||||
# newer than the runner-up, recency decides; otherwise ask
|
||||
newest, runner = live[-1], live[-2]
|
||||
if newest.created_turn - runner.created_turn >= 2:
|
||||
return {"proposal": newest}
|
||||
return {"ambiguous": True, "candidates": live}
|
||||
return {"proposal": live[0]}
|
||||
|
||||
|
||||
def _references(summary: str, low: str) -> bool:
|
||||
"""Does the user's text explicitly reference the proposal's content?
|
||||
Distinctive content words (len>4) appearing in the text revive a
|
||||
stale proposal (explicit > recency)."""
|
||||
words = {w for w in _tokens(summary) if len(w) > 4}
|
||||
return bool(words & set(_tokens(low)))
|
||||
|
||||
|
||||
def _consequence(text: str, context: ConversationContext) -> str:
|
||||
low = text.lower()
|
||||
if any(w in low for w in ("replace", "migrate", "rewrite", "delete",
|
||||
"remove all", "subsystem")):
|
||||
return "HIGH"
|
||||
if any(w in low for w in ("add", "extend", "port", "integrate")):
|
||||
return "MEDIUM"
|
||||
return "LOW"
|
||||
|
||||
|
||||
def _constraints(context: ConversationContext) -> list:
|
||||
"""User/project constraints recorded in recent turns (light capture;
|
||||
the constraint STORE is future Substrate's — §17O boundary)."""
|
||||
out = []
|
||||
for turn in context.recent_turns[-6:]:
|
||||
txt = turn.get("text", "").lower()
|
||||
if "must not" in txt or "don't" in txt or "never" in txt or \
|
||||
"no hardcoded" in txt:
|
||||
out.append(turn["text"][:120])
|
||||
return out
|
||||
|
||||
|
||||
def _intent(objective: str, action_intent: str, approval_state: str,
|
||||
confidence: float, ambiguity: str, ev: list, probes: list,
|
||||
context: ConversationContext, origin_context_id: str | None,
|
||||
requested_effect: str = "",
|
||||
consequence_level: str | None = None) -> ConstructionIntent:
|
||||
return ConstructionIntent(
|
||||
intent_id=f"intent-{uuid.uuid4().hex[:8]}",
|
||||
objective=objective,
|
||||
requested_effect=requested_effect or objective,
|
||||
resolved_target=context.active_project,
|
||||
resolved_referent=None,
|
||||
origin_context_id=origin_context_id,
|
||||
action_intent=action_intent,
|
||||
approval_state=approval_state,
|
||||
confidence=confidence, ambiguity=ambiguity,
|
||||
consequence_level=consequence_level
|
||||
or ("LOW" if action_intent == "DISCUSSION" else "MEDIUM"),
|
||||
relevant_constraints=_constraints(context),
|
||||
classification_evidence=ev, probe_questions=probes)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,395 @@
|
||||
"""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
|
||||
|
||||
# T17 §17Q: indefinite-article new-artifact evidence — "a small CLI
|
||||
# that renames photos", "build me a program that..." — is
|
||||
# new-lifecycle language even without explicit standalone keywords.
|
||||
# Guarded: requires a CREATION verb or artifact noun, and yields to
|
||||
# local/scope/file evidence (fix a bug / add a flag never counts).
|
||||
_CREATION_VERBS = {"build", "create", "make", "write", "develop"}
|
||||
_ARTIFACT_NOUNS = {"program", "cli", "tool", "app", "script", "service",
|
||||
"library", "package", "module", "dashboard",
|
||||
"website", "bot", "generator", "tracker", "game",
|
||||
"application", "command"}
|
||||
article_standalone = False
|
||||
if re.search(r"\b(a|an|another)\s+[a-z]", obj.lower()):
|
||||
creation_verb = bool(_tokens(obj.lower()) & _CREATION_VERBS)
|
||||
artifact_noun = bool(_tokens(obj.lower()) & _ARTIFACT_NOUNS)
|
||||
# structural evidence outranks lexical newness (§5): a known
|
||||
# existing target means article language describes a CHANGE
|
||||
# ("add a flag to the deploy script"), not a new project
|
||||
if (creation_verb or artifact_noun) and not local and not scoped \
|
||||
and not file_scoped and not target_project \
|
||||
and project_exists is not True:
|
||||
article_standalone = True
|
||||
ev.append("indefinite-article new-artifact language")
|
||||
|
||||
# 3. PROJECT_BUILD: standalone/new-lifecycle language AND no existing
|
||||
# project for the target (project existence materially matters, §5)
|
||||
if standalone or article_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:
|
||||
|
||||
@@ -187,6 +187,9 @@ class DriftCache:
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
import os
|
||||
|
||||
SCRIPTS_DIR = os.environ.get("RUNE_SCRIPTS_DIR", os.path.join(os.path.dirname(__file__), "scripts"))
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
|
||||
@@ -350,63 +350,6 @@ def pre_commit_gate_entry(entry: dict) -> dict:
|
||||
"critics": critics,
|
||||
"reason": "all critics passed" if all_pass else "one or more critics failed",
|
||||
}
|
||||
row = db.execute(
|
||||
"SELECT id, text, importance, wash_count, rock_status, topic FROM entries WHERE id = ?", (entry_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return {"verdict": "REJECT", "reason": "entry not found"}
|
||||
|
||||
entry = dict(row)
|
||||
critics = {}
|
||||
|
||||
# Critic 1: Structural validity
|
||||
text = entry.get("text", "")
|
||||
if len(text) < 5:
|
||||
critics["structural"] = {"pass": False, "reason": "text too short"}
|
||||
elif len(text) > 2000:
|
||||
critics["structural"] = {"pass": False, "reason": "text too long"}
|
||||
elif not text.strip():
|
||||
critics["structural"] = {"pass": False, "reason": "empty text"}
|
||||
else:
|
||||
critics["structural"] = {"pass": True, "reason": "well-formed"}
|
||||
|
||||
# Critic 2: Behavioral harmlessness (check against immutable core)
|
||||
# The immutable core: user authority, no safety theater, no fights
|
||||
harmful_patterns = ["refuse to", "cannot comply", "safety violation", "I'm sorry, but I can't"]
|
||||
is_harmful = any(p in text.lower() for p in harmful_patterns)
|
||||
critics["behavioral"] = {
|
||||
"pass": not is_harmful,
|
||||
"reason": "contains refusal language" if is_harmful else "aligned with core",
|
||||
}
|
||||
|
||||
# Critic 3: Semantic consistency (check for contradictions with existing rocks)
|
||||
rocks = db.execute("SELECT text FROM entries WHERE rock_status = 'rock' AND status = 'active' LIMIT 50").fetchall()
|
||||
contradiction_found = False
|
||||
for rock in rocks:
|
||||
rock_text = rock[0].lower()
|
||||
entry_text = text.lower()
|
||||
# Simple contradiction detection: if entry says opposite of a rock
|
||||
if ("always" in rock_text and "never" in entry_text) or ("never" in rock_text and "always" in entry_text):
|
||||
contradiction_found = True
|
||||
break
|
||||
critics["semantic"] = {
|
||||
"pass": not contradiction_found,
|
||||
"reason": "contradicts existing rock" if contradiction_found else "consistent",
|
||||
}
|
||||
|
||||
# Overall verdict
|
||||
all_pass = all(c["pass"] for c in critics.values())
|
||||
verdict = "PASS" if all_pass else "REJECT"
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"entry_id": entry_id,
|
||||
"critics": critics,
|
||||
"reason": "all critics passed" if all_pass else "one or more critics failed",
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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}
|
||||
|
||||
|
||||
# ── T18 §18H: structural drift detection (extends drift_resume; no
|
||||
# duplicate mechanism). Detects observable drift between a structural
|
||||
# model's claims and repository reality. REALITY WINS unless user-locked.
|
||||
|
||||
def detect_structural_drift(model, repo_root) -> list[dict]:
|
||||
"""Compare structural-model component claims against source reality.
|
||||
Observable cases: source file moved/missing (component identities
|
||||
carry 'source'), entities for files that no longer exist. Returns
|
||||
drift records in the classify_drift vocabulary; reality wins unless
|
||||
the claim is user-locked (authority == 'user' on the entity's
|
||||
evidence or identities.user_locked)."""
|
||||
import os
|
||||
from pathlib import Path as _P
|
||||
root = _P(repo_root)
|
||||
drifts = []
|
||||
for e in model.doc["entities"]:
|
||||
if e.get("kind") != "component":
|
||||
continue
|
||||
src = ((e.get("ext") or {}).get("source")
|
||||
or (e.get("identities") or {}).get("source"))
|
||||
if not src:
|
||||
continue
|
||||
locked = (e.get("identities") or {}).get("user_locked") is True
|
||||
expected = root / src
|
||||
if not expected.exists():
|
||||
# reality: file gone/moved — search one level for a moved twin
|
||||
moved = None
|
||||
if expected.name:
|
||||
for cand in root.rglob(expected.name):
|
||||
if cand.is_file():
|
||||
moved = str(cand.relative_to(root))
|
||||
break
|
||||
drifts.append({
|
||||
"claim": f"{e['id']} source at {src}",
|
||||
"class": "CONFLICTING" if locked else "MATERIAL",
|
||||
"evidence": (f"source missing; moved to {moved}"
|
||||
if moved else "source file absent from repo"),
|
||||
"reality_wins": not locked,
|
||||
"reconciliation": (f"update source to {moved}" if moved
|
||||
else "entity requires re-derivation or "
|
||||
"user-locked preservation"),
|
||||
})
|
||||
return drifts
|
||||
@@ -0,0 +1,517 @@
|
||||
"""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:
|
||||
"""Resolve the bridge DATA root — DELEGATED (T21.5 §7) to the single
|
||||
shared resolver (scripts/wr_root_resolver.py): explicit env →
|
||||
canonical production root → legacy home; split-brain between live
|
||||
stores fails loudly, never silently operates two stores."""
|
||||
resolver_path = (Path(__file__).resolve().parent.parent.parent
|
||||
/ "scripts" / "wr_root_resolver.py")
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"wr_root_resolver", str(resolver_path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("no spec/loader")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.resolve_root()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
# resolver unavailable (partial checkout): keep the documented
|
||||
# policy rather than failing the wait path
|
||||
sys.stderr.write(f"event_wait: resolver fallback: {exc}\n")
|
||||
env = os.environ.get("RUNE_WR_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
shared = Path("/mnt/workdrive/wr-bridge")
|
||||
if shared.exists():
|
||||
return shared
|
||||
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,
|
||||
continuation_state: str = "WAITING_EXTERNAL",
|
||||
op_type: str = "",
|
||||
authority: str = "",
|
||||
task_id: str = "",
|
||||
resume_mode: str = "fifo") -> dict:
|
||||
"""E-PATCH §4: the PendingOperation IS the durable continuation object.
|
||||
|
||||
continuation_state is first-class cognitive quiescence:
|
||||
ACTIVE_REASONING | WAITING_EXTERNAL | RESUMABLE | RECOVERY_REQUIRED
|
||||
op_type/authority record the CANONICAL execution semantics (derived by
|
||||
intent normalization, never model-supplied). task_id/project bind the
|
||||
operation to the current programme so fresh-process recovery can
|
||||
resolve relevance without the model reconstructing state.
|
||||
|
||||
resume_mode owns the SESSION-DELIVERY domain (REAL_ASYNC_BRIDGE §2/§3):
|
||||
"fifo" — legacy delivery: the daemon writes a resume token to the
|
||||
operation's FIFO on event match (T15 semantics; the waiter
|
||||
blocks reading the FIFO). Default preserves T15 builder
|
||||
behavior.
|
||||
"self" — the waiter process itself blocks on the durable completion
|
||||
file (inotify) and stamps its own terminal operation state
|
||||
on authority verification. NO resume_cmd is registered: the
|
||||
daemon must never dispatch a delivery worker for this wait,
|
||||
and a dead reader cannot downgrade operation truth."""
|
||||
"""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)")
|
||||
if resume_mode == "self":
|
||||
# REAL_ASYNC_BRIDGE §3: no session-delivery worker. The waiter
|
||||
# self-resumes on the durable completion file; the daemon's only
|
||||
# roles are event persistence and failure-detection sweeps.
|
||||
resume_cmd = None
|
||||
else:
|
||||
resume_cmd = ["python3", "-c", writer, str(fifo), "{wait_id}"]
|
||||
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=resume_cmd,
|
||||
)
|
||||
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")),
|
||||
"continuation_state": continuation_state,
|
||||
"op_type": op_type, "authority": authority,
|
||||
"task_id": task_id,
|
||||
# §3 delivery-domain marker: the daemon drive loop skips
|
||||
# waits marked "self" (the waiter owns its continuation);
|
||||
# records WITHOUT the marker remain eligible for the
|
||||
# env-template session-delivery path (WR_RESUME_CMD).
|
||||
"resume_mode": resume_mode}
|
||||
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,146 @@
|
||||
"""forge.py — T18 §18L: minimum stable forge abstraction boundary.
|
||||
|
||||
Rune today is Gitea-centric; Scaffolding must not make Gitea itself part
|
||||
of canonical construction semantics. This module defines the boundary:
|
||||
Git remains the source-history authority; a Forge adapter handles the
|
||||
remote-services surface (push/CI status/PR/protected checks/release
|
||||
metadata). GiteaForge is the real working adapter, delegating to the
|
||||
existing gitea_auth + scripts (rule A: no duplicate implementation).
|
||||
|
||||
A future Forgejo/GitHub/GitLab adapter implements the same interface
|
||||
without changing Scaffolding's core model.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class ForgeAdapter(Protocol):
|
||||
"""The minimum stable forge boundary. All methods may raise
|
||||
ForgeUnavailable — callers treat forge failures as blocking-for-that-
|
||||
operation, never as construction-truth changes."""
|
||||
|
||||
def push(self, repo: str, branch: str) -> dict: ...
|
||||
|
||||
def commit_status(self, repo: str, sha: str) -> str: ...
|
||||
|
||||
def ci_runs_for_sha(self, repo: str, sha: str) -> list[dict]: ...
|
||||
|
||||
def create_or_patch_release(self, repo: str, tag: str,
|
||||
title: str, body: str) -> dict: ...
|
||||
|
||||
|
||||
class ForgeUnavailable(RuntimeError):
|
||||
"""The forge could not be reached or refused the operation."""
|
||||
|
||||
|
||||
class GiteaForge:
|
||||
"""Real adapter. Delegates to the existing canonical implementations
|
||||
(gitea_auth credential ladder; deploy_qualify verdict logic) — no
|
||||
duplicated API clients (rule A)."""
|
||||
|
||||
def __init__(self, server: str | None = None, repo: str = "conductor/rune"):
|
||||
self._server = server # None → scripts' env/credential resolution
|
||||
self._repo = repo
|
||||
|
||||
def _env(self) -> dict:
|
||||
import os
|
||||
env = dict(os.environ)
|
||||
if self._server:
|
||||
env["GITEA_SERVER"] = self._server
|
||||
env.setdefault("GITEA_REPO", self._repo)
|
||||
return env
|
||||
|
||||
def push(self, repo: str, branch: str) -> dict:
|
||||
import subprocess
|
||||
proc = subprocess.run(["git", "push", "gitea", f"master:{branch}"],
|
||||
capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise ForgeUnavailable(f"push failed: {proc.stderr[:200]}")
|
||||
return {"pushed": True, "branch": branch}
|
||||
|
||||
@staticmethod
|
||||
def _gitea_auth():
|
||||
"""Canonical scripts/gitea_auth credential ladder (rule A: no
|
||||
duplicate auth). scripts/ is not a package — path shim, the
|
||||
established spec-loaded-scripts pattern."""
|
||||
import sys
|
||||
from pathlib import Path as _P
|
||||
scripts = _P(__file__).resolve().parents[2] / "scripts"
|
||||
if str(scripts) not in sys.path:
|
||||
sys.path.insert(0, str(scripts))
|
||||
import gitea_auth
|
||||
return gitea_auth
|
||||
|
||||
def commit_status(self, repo: str, sha: str) -> str:
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
gitea_auth = self._gitea_auth()
|
||||
server = self._server or os.environ.get(
|
||||
"GITEA_SERVER", "https://gitea.thingscouldgetdicey.online")
|
||||
if not gitea_auth.has_token():
|
||||
raise ForgeUnavailable("no forge credentials (gitea token)")
|
||||
req = urllib.request.Request(
|
||||
f"{server}/api/v1/repos/{repo}/commits/{sha}/status",
|
||||
headers={"Authorization": f"token {gitea_auth.get_token()}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
data = json.loads(r.read())
|
||||
except OSError as exc:
|
||||
raise ForgeUnavailable(f"status unreachable: {exc}") from exc
|
||||
return data.get("state", "unknown")
|
||||
|
||||
def ci_runs_for_sha(self, repo: str, sha: str) -> list[dict]:
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
gitea_auth = self._gitea_auth()
|
||||
server = self._server or os.environ.get(
|
||||
"GITEA_SERVER", "https://gitea.thingscouldgetdicey.online")
|
||||
if not gitea_auth.has_token():
|
||||
raise ForgeUnavailable("no forge credentials (gitea token)")
|
||||
req = urllib.request.Request(
|
||||
f"{server}/api/v1/repos/{repo}/actions/runs?limit=50",
|
||||
headers={"Authorization": f"token {gitea_auth.get_token()}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
data = json.loads(r.read())
|
||||
except OSError as exc:
|
||||
raise ForgeUnavailable(f"runs unreachable: {exc}") from exc
|
||||
return [run for run in data.get("workflow_runs", [])
|
||||
if str(run.get("head_sha", "")) == sha]
|
||||
|
||||
def create_or_patch_release(self, repo: str, tag: str,
|
||||
title: str, body: str) -> dict:
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
gitea_auth = self._gitea_auth()
|
||||
server = self._server or os.environ.get(
|
||||
"GITEA_SERVER", "https://gitea.thingscouldgetdicey.online")
|
||||
if not gitea_auth.has_token():
|
||||
raise ForgeUnavailable("no forge credentials (gitea token)")
|
||||
# delegate to the canonical idempotent publisher
|
||||
import subprocess
|
||||
import sys
|
||||
script = None
|
||||
for cand in ("scripts/create_release.py",):
|
||||
if (sys.path and __import__("pathlib").Path(cand).is_file()):
|
||||
script = cand
|
||||
break
|
||||
if script:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, script, "--tag", tag, "--title", title,
|
||||
"--body", body], capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise ForgeUnavailable(
|
||||
f"release publication failed rc={proc.returncode}: "
|
||||
f"{(proc.stderr or proc.stdout)[-200:]}")
|
||||
return {"published": True, "tag": tag}
|
||||
raise ForgeUnavailable("canonical release publisher not found")
|
||||
|
||||
|
||||
def default_forge() -> ForgeAdapter:
|
||||
"""The working adapter (rule 18L: prove Gitea still works)."""
|
||||
return GiteaForge()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""graph_projection.py — T18 §18Q: stable versioned READ-ONLY graph
|
||||
projection from a structural Scaffold, suitable for later local-web
|
||||
visualisation, GUI, TUI tree, or programmatic queries.
|
||||
|
||||
This is a PROJECTION — never another authority (rule 18Q). Structural
|
||||
truth remains the Scaffold (structural.json) / Spine. Built THROUGH the
|
||||
self-scaffolded upgrade (contract 18q-graph-projection): the upgrade
|
||||
started from the maintained Scaffold, per the 18P proof.
|
||||
|
||||
Formats: versioned JSON (programmatic), DOT and Mermaid (visual —
|
||||
dependency-cruiser-proven human-usable formats per the 18D research).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PROJECTION_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def project_json(model) -> dict:
|
||||
"""Stable versioned JSON projection: nodes + edges, no internal
|
||||
storage layout leaked (Substrate-safe seam, rule 18E)."""
|
||||
nodes = []
|
||||
for e in model.doc["entities"]:
|
||||
nodes.append({
|
||||
"id": e["id"],
|
||||
"kind": e.get("kind"),
|
||||
"maturity": e.get("maturity"),
|
||||
"name": (e.get("identities") or {}).get("name"),
|
||||
"ext": {k: v for k, v in (e.get("ext") or {}).items()
|
||||
if k in ("source", "bounded_adapter")},
|
||||
})
|
||||
edges = []
|
||||
for r in model.doc["relationships"]:
|
||||
edges.append({
|
||||
"type": r.get("type"),
|
||||
"from": r.get("from"),
|
||||
"to": r.get("to"),
|
||||
"authority": r.get("authority"),
|
||||
"status": r.get("status"),
|
||||
})
|
||||
return {
|
||||
"schema_version": PROJECTION_SCHEMA_VERSION,
|
||||
"project": getattr(model, "project_id", ""),
|
||||
"generated_at": _now(),
|
||||
"node_count": len(nodes),
|
||||
"edge_count": len(edges),
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
}
|
||||
|
||||
|
||||
def project_dot(model) -> str:
|
||||
"""Graphviz DOT — clusters per domain, edges labeled by type."""
|
||||
lines = ["digraph structural {", " rankdir=LR;",
|
||||
' node [shape=box, style="rounded,filled", fillcolor="#eef"];']
|
||||
for e in model.doc["entities"]:
|
||||
eid = e["id"].replace('"', "'")
|
||||
label = (e.get("identities") or {}).get("name") or \
|
||||
e["id"].split(":", 1)[-1]
|
||||
lines.append(f' "{eid}" [label="{label} ({e["kind"]})"];')
|
||||
for r in model.doc["relationships"]:
|
||||
if r["type"] == "belongs_to" and r.get("status") == "root":
|
||||
continue
|
||||
a = str(r["from"]).replace('"', "'")
|
||||
b = str(r["to"]).replace('"', "'")
|
||||
lines.append(f' "{a}" -> "{b}" [label="{r["type"]}"];')
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def project_mermaid(model) -> str:
|
||||
"""Mermaid flowchart — renders on GitHub/GitLab/many viewers."""
|
||||
lines = ["graph LR"]
|
||||
ids = {e["id"] for e in model.doc["entities"]}
|
||||
safe = {}
|
||||
for i, eid in enumerate(sorted(ids)):
|
||||
safe[eid] = f"n{i}"
|
||||
for e in model.doc["entities"]:
|
||||
label = ((e.get("identities") or {}).get("name")
|
||||
or e["id"].split(":", 1)[-1]).replace('"', "'")
|
||||
lines.append(f' {safe[e["id"]]}["{label} ({e["kind"]})"]')
|
||||
for r in model.doc["relationships"]:
|
||||
if r["type"] == "belongs_to" and r.get("status") == "root":
|
||||
continue
|
||||
if r["from"] in safe and r["to"] in safe:
|
||||
lines.append(f' {safe[r["from"]]} -->|{r["type"]}| '
|
||||
f'{safe[r["to"]]}')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_all(model, out_dir) -> dict:
|
||||
"""Write all three formats; returns paths. Read-only over truth —
|
||||
writing projections never mutates the model."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
j = out / "structural_graph.json"
|
||||
d = out / "structural_graph.dot"
|
||||
mm = out / "structural_graph.mmd"
|
||||
j.write_text(json.dumps(project_json(model), indent=1, sort_keys=True))
|
||||
d.write_text(project_dot(model))
|
||||
mm.write_text(project_mermaid(model))
|
||||
return {"json": str(j), "dot": str(d), "mermaid": str(mm)}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
import time
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""impact_preview.py — T18 §18G: visible change-impact preview.
|
||||
|
||||
For consequential changes, a user-visible STRUCTURED projection of what
|
||||
a construction will touch — BEFORE build. Derived from the structural
|
||||
Scaffold's impact closure + the Build Basis summary. Contains
|
||||
structured conclusions/evidence only — NEVER private chain-of-thought
|
||||
(rule 18G). This is a projection, not an authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PREVIEW_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def build_preview(model, closure: dict, *, basis: dict | None = None,
|
||||
contract_scope: list[str] | None = None) -> dict:
|
||||
"""Assemble the preview from an impact_closure (structural_model)
|
||||
and optionally the Build Basis summary. Pure derivation — no hidden
|
||||
reasoning, no mutation."""
|
||||
if closure.get("error"):
|
||||
return {"schema_version": PREVIEW_SCHEMA_VERSION,
|
||||
"error": closure["error"]}
|
||||
scope = set(closure.get("mutation_scope", []))
|
||||
observation = closure.get("observation_scope") or {}
|
||||
deps = observation.get("direct_dependencies", [])
|
||||
# candidate files: identities' source paths if modeled
|
||||
candidate_files = []
|
||||
for eid in sorted(scope):
|
||||
e = model.get(eid) or {}
|
||||
src = (e.get("identities") or {}).get("source")
|
||||
if src:
|
||||
candidate_files.append(src)
|
||||
return {
|
||||
"schema_version": PREVIEW_SCHEMA_VERSION,
|
||||
"target": closure.get("target"),
|
||||
"affected_domains": ([closure.get("owning_domain")]
|
||||
if closure.get("owning_domain") else []),
|
||||
"affected_subsystems": sorted(
|
||||
e for e in scope if str(e).startswith("subsystem:")),
|
||||
"consumers_connectors": sorted(
|
||||
e for e in scope if e != closure.get("target")),
|
||||
"candidate_files": sorted(set(candidate_files))[:50],
|
||||
"contract_scope_additions": sorted(
|
||||
set(contract_scope or []) - set(candidate_files)),
|
||||
"state_implications": closure.get("state_surfaces", []),
|
||||
"environment_runtime_implications": sorted(
|
||||
model.deployment_surfaces(closure["target"])
|
||||
if model.get(closure["target"]) else []),
|
||||
"migration_implications": (basis or {}).get("implications",
|
||||
{}).get("migration", []),
|
||||
"risks_uncertainties": [
|
||||
*(basis or {}).get("risks", []),
|
||||
*closure.get("uncertainty", []),
|
||||
],
|
||||
"verification_plan": closure.get("verification_scope", []),
|
||||
"rollback_implications": [
|
||||
"revert contract scope commits; deployment rollbacks via "
|
||||
"last-good symlink (canonical deploy machinery)"],
|
||||
"basis_summary": (basis or {}).get("depth"),
|
||||
}
|
||||
|
||||
|
||||
def validate_preview(preview: dict) -> list[str]:
|
||||
errs = []
|
||||
if preview.get("schema_version") != PREVIEW_SCHEMA_VERSION:
|
||||
errs.append("schema_version mismatch")
|
||||
for f in ("target", "verification_plan", "risks_uncertainties"):
|
||||
if f not in preview:
|
||||
errs.append(f"missing {f}")
|
||||
return errs
|
||||
@@ -0,0 +1,190 @@
|
||||
"""iteration.py — T19 §19E/19H/19K: the canonical dependency-aware
|
||||
iteration engine — the "quicksand" solution as STANDARD Scaffolding
|
||||
behaviour.
|
||||
|
||||
Canonical loop (rule 19H):
|
||||
|
||||
intent → target → research/Build Basis → scaffold reconcile →
|
||||
PREDICTIVE impact → bounded mutation scope + wider observation
|
||||
scope → preview → isolated construction → verification →
|
||||
DESCRIPTIVE impact → predicted-vs-observed delta → structural
|
||||
truth update → selective validation invalidation → obligations →
|
||||
iterate → harden.
|
||||
|
||||
Built ON the T18 foundation (rule C): structural_model.impact_closure,
|
||||
build_basis, impact_preview, drift_resume — no second anything.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from rune.capability_ledger import CapabilityLedger
|
||||
from rune.behaviour_ledger import BehaviourLedger
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
# ── 19E: surgical context packets ────────────────────────────────────────
|
||||
|
||||
def context_packet(model, ledger: CapabilityLedger, target: str, *,
|
||||
intent: str = "", basis_summary: dict | None = None,
|
||||
max_local_files: int = 12) -> dict:
|
||||
"""LOCAL + CONNECTED context for one structural target (rule 19E).
|
||||
Bounded and proportional: the whole graph is NEVER dumped. Local =
|
||||
the target's own ledger entry; connected = 1-hop structural
|
||||
neighbours with their ledgers on demand."""
|
||||
entity = model.get(target)
|
||||
if entity is None:
|
||||
return {"error": f"unknown structural target {target!r}"}
|
||||
caps = ledger.by_component(target)
|
||||
local = ledger.context_packet(caps[0]["capability_id"]) if caps else None
|
||||
beneath = [b for b in model.beneath(target)
|
||||
if not model.get(b).get("identities", {}).get("source")]
|
||||
files: list[str] = []
|
||||
for eid in [target] + model.children(target):
|
||||
e = model.get(eid) or {}
|
||||
src = (e.get("ext") or {}).get("source") or \
|
||||
(e.get("identities") or {}).get("source")
|
||||
if src:
|
||||
files.append(src)
|
||||
for cap in caps:
|
||||
files.extend(cap.get("source_locations", []))
|
||||
closure = model.impact_closure(target)
|
||||
# interface-mediated consumers: consumers of the interfaces this
|
||||
# target provides (1-hop through the connector, still bounded)
|
||||
iface_consumers = sorted({c for iface in model.interfaces_for(target)
|
||||
for c in model.consumers(iface)
|
||||
if c != target})
|
||||
connected = {
|
||||
"dependencies": model.dependencies(target),
|
||||
"consumers": sorted(set(model.consumers(target)) |
|
||||
set(iface_consumers)),
|
||||
"interface_mediated_consumers": iface_consumers,
|
||||
"interfaces_provided": model.interfaces_for(target),
|
||||
"state_owned": model.state_owned(target),
|
||||
"verification": model.verification_surfaces(target),
|
||||
"deployment": model.deployment_surfaces(target),
|
||||
"uncertainty": closure.get("uncertainty", []),
|
||||
}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"intent": intent,
|
||||
"target": target,
|
||||
"target_kind": entity.get("kind"),
|
||||
"owning_domain": model.owning_domain(target),
|
||||
"local": local,
|
||||
"local_files": sorted(set(files))[:max_local_files],
|
||||
"beneath_count": len(beneath),
|
||||
"connected": connected,
|
||||
"basis_summary": basis_summary,
|
||||
"generated_at": _now(),
|
||||
}
|
||||
|
||||
|
||||
# ── 19H: predictive vs descriptive impact ────────────────────────────────
|
||||
|
||||
def predictive_impact(model, target: str) -> dict:
|
||||
"""What MAY be affected BEFORE the change (structural closure)."""
|
||||
return model.impact_closure(target)
|
||||
|
||||
|
||||
def descriptive_impact(model, target: str, *,
|
||||
observed_changed: list[str],
|
||||
generation: str = "") -> dict:
|
||||
"""What was MATERIALLY affected AFTER the change — from actual
|
||||
evidence (diff/observations), never assumed from the graph alone."""
|
||||
predicted = model.impact_closure(target)
|
||||
predicted_scope = set(predicted.get("mutation_scope", []))
|
||||
observed = set(observed_changed)
|
||||
return {
|
||||
"target": target,
|
||||
"generation": generation,
|
||||
"predicted": sorted(predicted_scope),
|
||||
"observed": sorted(observed),
|
||||
"unexpected": sorted(observed - predicted_scope), # prediction missed
|
||||
"unaffected_as_predicted": sorted(predicted_scope - observed),
|
||||
"recorded_at": _now(),
|
||||
}
|
||||
|
||||
|
||||
def record_impact_delta(project_store: Path, delta: dict) -> Path:
|
||||
"""Persist predicted-vs-observed deltas — they compound into better
|
||||
future impact prediction (rule 19H)."""
|
||||
p = Path(project_store) / "impact_deltas.jsonl"
|
||||
import json
|
||||
with p.open("a") as f:
|
||||
f.write(json.dumps(delta, sort_keys=True) + "\n")
|
||||
return p
|
||||
|
||||
|
||||
# ── 19I: selective invalidation bridge ───────────────────────────────────
|
||||
|
||||
def invalidate_behaviours(model, behaviours: BehaviourLedger, *,
|
||||
changed_entities: set[str], generation: str = ""
|
||||
) -> list[dict]:
|
||||
"""Selective invalidation driven by STRUCTURAL truth: behaviours
|
||||
whose dependencies/owner intersect the changed closure reopen;
|
||||
everything else keeps its confidence (rule 19I)."""
|
||||
return behaviours.invalidate_for_change(
|
||||
changed_entities=changed_entities, generation=generation)
|
||||
|
||||
|
||||
# ── 19K: post-change obligations ─────────────────────────────────────────
|
||||
|
||||
def post_change_obligations(model, target: str, *,
|
||||
behaviours: BehaviourLedger | None = None,
|
||||
changed_entities: set[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Machine-derived follow-up obligations from STRUCTURAL truth
|
||||
(rule 19K) — never an improvised generic checklist."""
|
||||
changed = changed_entities or {target}
|
||||
obligations: list[dict] = []
|
||||
seen: set[tuple] = set()
|
||||
|
||||
def add(kind: str, target_e: str, why: str) -> None:
|
||||
key = (kind, target_e)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
obligations.append({"kind": kind, "target": target_e,
|
||||
"why": why})
|
||||
|
||||
for eid in sorted(changed):
|
||||
# consumers of a changed component must be inspected
|
||||
for consumer in model.consumers(eid):
|
||||
add("inspect_consumer", consumer,
|
||||
f"consumes/depends on changed {eid}")
|
||||
# provided interfaces: every consumer of the interface is affected
|
||||
for iface in model.interfaces_for(eid):
|
||||
add("verify_interface", iface,
|
||||
f"provided by changed {eid}")
|
||||
for consumer in model.consumers(iface):
|
||||
add("inspect_consumer", consumer,
|
||||
f"consumes {iface} provided by changed {eid}")
|
||||
# state compatibility
|
||||
for store in model.state_owned(eid):
|
||||
add("check_state_compatibility", store,
|
||||
f"state owned by changed {eid}")
|
||||
# interfaces the target consumes
|
||||
for iface in model.consumers(eid) if False else []:
|
||||
pass
|
||||
consumed = sorted(r["to"] for r in model.doc["relationships"]
|
||||
if r["type"] == "consumes" and r["from"] == eid)
|
||||
for iface in consumed:
|
||||
add("check_interface_contract", iface,
|
||||
f"changed {eid} consumes {iface} — contract must still hold")
|
||||
# deployment
|
||||
for dep in model.deployment_surfaces(eid):
|
||||
add("verify_deployment", dep,
|
||||
f"deployed surface of changed {eid}")
|
||||
# behaviour retests
|
||||
if behaviours is not None:
|
||||
for rec in behaviours.doc["behaviours"].values():
|
||||
deps = set(rec.get("dependencies") or []) | {
|
||||
rec.get("owner_component")}
|
||||
if deps & changed:
|
||||
add("retest", rec["behaviour_id"],
|
||||
f"depends on changed {sorted(deps & changed)}")
|
||||
return obligations
|
||||
@@ -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
|
||||
@@ -241,6 +241,13 @@ class TaskStateRepository:
|
||||
with self._lock:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_all(self) -> list[TaskStateRecord]:
|
||||
"""Every tracked task record (T17 17F: user-facing counters and
|
||||
listings derive from THIS authority, not the legacy operator
|
||||
cache)."""
|
||||
with self._lock:
|
||||
return list(self._tasks.values())
|
||||
|
||||
def list_open(self, channel_id: str) -> list[TaskStateRecord]:
|
||||
with self._lock:
|
||||
return sorted(self._open_in_channel(channel_id), key=lambda t: t.updated_at)
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build Mode Plugin — State machine orchestrator.
|
||||
|
||||
Each phase is an independent module. The state machine coordinates:
|
||||
1. Read current state from checkpoint file
|
||||
2. Execute the current phase's module
|
||||
3. Write output to checkpoint file
|
||||
4. Advance to next phase
|
||||
5. Repeat
|
||||
|
||||
Key insight from Gauntlet: "Checkpoint files on disk. Reports generated
|
||||
from checkpoints, not memory." If the conversation dies, the next session
|
||||
resumes from the last checkpoint. No drift because state is on disk.
|
||||
|
||||
Architecture:
|
||||
- Each phase is a separate module in phases/
|
||||
- State machine reads checkpoint.json to know current phase
|
||||
- Each phase writes its output to checkpoint.json
|
||||
- Cron tasks fire each phase independently
|
||||
- Ledger tracks progress for resumability
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PHASES_DIR = os.path.join(PLUGIN_DIR, "phases")
|
||||
BUILDS_DIR = os.path.expanduser("~/.rune/work/builds")
|
||||
WORKTREE_DIR = os.path.expanduser("~/.rune/worktrees")
|
||||
REPO_DIR = "/mnt/workdrive/rune"
|
||||
|
||||
# Phase execution order
|
||||
PHASE_ORDER = [
|
||||
"assess",
|
||||
"research",
|
||||
"feasible",
|
||||
"consult",
|
||||
"consent",
|
||||
"plan",
|
||||
"build",
|
||||
"verify",
|
||||
"correct",
|
||||
"integrate",
|
||||
"record",
|
||||
]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# STATE MACHINE
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class BuildStateMachine:
|
||||
"""State machine for build mode. Reads/writes checkpoint files."""
|
||||
|
||||
def __init__(self, build_id: str):
|
||||
self.build_id = build_id
|
||||
self.build_dir = os.path.join(BUILDS_DIR, build_id)
|
||||
self.checkpoint_path = os.path.join(self.build_dir, "checkpoint.json")
|
||||
self.ledger_path = os.path.join(self.build_dir, "ledger.json")
|
||||
self.trajectory_path = os.path.join(self.build_dir, "trajectory.json")
|
||||
|
||||
os.makedirs(self.build_dir, exist_ok=True)
|
||||
|
||||
def load_checkpoint(self) -> Dict[str, Any]:
|
||||
"""Load current state from checkpoint file."""
|
||||
if os.path.exists(self.checkpoint_path):
|
||||
with open(self.checkpoint_path) as f:
|
||||
return json.load(f)
|
||||
return self._initial_checkpoint()
|
||||
|
||||
def save_checkpoint(self, checkpoint: Dict[str, Any]):
|
||||
"""Save state to checkpoint file."""
|
||||
checkpoint["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
with open(self.checkpoint_path, "w") as f:
|
||||
json.dump(checkpoint, f, indent=2)
|
||||
|
||||
def _initial_checkpoint(self) -> Dict[str, Any]:
|
||||
"""Create initial checkpoint."""
|
||||
return {
|
||||
"build_id": self.build_id,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"current_phase": "assess",
|
||||
"phase_status": "pending",
|
||||
"phases": {},
|
||||
"description": "",
|
||||
"worktree_path": None,
|
||||
"complexity_score": 0,
|
||||
"estimated_steps": 0,
|
||||
"batches": [],
|
||||
"corrections": 0,
|
||||
"max_corrections": 3,
|
||||
"files_changed": [],
|
||||
"contracts": [],
|
||||
"failure_patterns": [],
|
||||
}
|
||||
|
||||
def get_current_phase(self) -> str:
|
||||
"""Get current phase from checkpoint."""
|
||||
checkpoint = self.load_checkpoint()
|
||||
return checkpoint.get("current_phase", "assess")
|
||||
|
||||
def get_phase_status(self) -> str:
|
||||
"""Get current phase status from checkpoint."""
|
||||
checkpoint = self.load_checkpoint()
|
||||
return checkpoint.get("phase_status", "pending")
|
||||
|
||||
def advance_phase(self):
|
||||
"""Advance to next phase."""
|
||||
checkpoint = self.load_checkpoint()
|
||||
current = checkpoint["current_phase"]
|
||||
|
||||
# Mark current phase as complete
|
||||
checkpoint["phases"][current] = "complete"
|
||||
|
||||
# Find next phase
|
||||
try:
|
||||
idx = PHASE_ORDER.index(current)
|
||||
if idx + 1 < len(PHASE_ORDER):
|
||||
next_phase = PHASE_ORDER[idx + 1]
|
||||
checkpoint["current_phase"] = next_phase
|
||||
checkpoint["phase_status"] = "pending"
|
||||
else:
|
||||
checkpoint["phase_status"] = "complete"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self.save_checkpoint(checkpoint)
|
||||
|
||||
def set_phase_status(self, status: str, details: Dict[str, Any] = None):
|
||||
"""Set current phase status."""
|
||||
checkpoint = self.load_checkpoint()
|
||||
checkpoint["phase_status"] = status
|
||||
if details:
|
||||
checkpoint.update(details)
|
||||
self.save_checkpoint(checkpoint)
|
||||
|
||||
def log_to_ledger(self, entry: Dict[str, Any]):
|
||||
"""Append entry to ledger."""
|
||||
ledger = []
|
||||
if os.path.exists(self.ledger_path):
|
||||
with open(self.ledger_path) as f:
|
||||
ledger = json.load(f)
|
||||
|
||||
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
ledger.append(entry)
|
||||
|
||||
with open(self.ledger_path, "w") as f:
|
||||
json.dump(ledger, f, indent=2)
|
||||
|
||||
def log_trajectory(self, action: str, details: Dict[str, Any] = None):
|
||||
"""Append to trajectory log (like SWE-agent .traj files)."""
|
||||
trajectory = []
|
||||
if os.path.exists(self.trajectory_path):
|
||||
with open(self.trajectory_path) as f:
|
||||
trajectory = json.load(f)
|
||||
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"action": action,
|
||||
"details": details or {},
|
||||
}
|
||||
trajectory.append(entry)
|
||||
|
||||
with open(self.trajectory_path, "w") as f:
|
||||
json.dump(trajectory, f, indent=2)
|
||||
|
||||
def get_resume_point(self) -> Optional[str]:
|
||||
"""Get phase to resume from."""
|
||||
checkpoint = self.load_checkpoint()
|
||||
current = checkpoint.get("current_phase")
|
||||
status = checkpoint.get("phase_status")
|
||||
|
||||
if status == "complete":
|
||||
# All phases complete
|
||||
return None
|
||||
|
||||
return current
|
||||
|
||||
def can_resume(self) -> bool:
|
||||
"""Check if build can be resumed."""
|
||||
return self.get_resume_point() is not None
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get build status."""
|
||||
checkpoint = self.load_checkpoint()
|
||||
return {
|
||||
"build_id": self.build_id,
|
||||
"description": checkpoint.get("description", ""),
|
||||
"current_phase": checkpoint.get("current_phase", "assess"),
|
||||
"phase_status": checkpoint.get("phase_status", "pending"),
|
||||
"phases": checkpoint.get("phases", {}),
|
||||
"batches": len(checkpoint.get("batches", [])),
|
||||
"corrections": checkpoint.get("corrections", 0),
|
||||
"worktree": checkpoint.get("worktree_path"),
|
||||
"complexity_score": checkpoint.get("complexity_score", 0),
|
||||
"estimated_steps": checkpoint.get("estimated_steps", 0),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# PHASE EXECUTION
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def execute_phase(build_id: str, phase: str = None) -> Dict[str, Any]:
|
||||
"""Execute a phase. If phase is None, execute current phase."""
|
||||
sm = BuildStateMachine(build_id)
|
||||
|
||||
if phase is None:
|
||||
phase = sm.get_current_phase()
|
||||
|
||||
# Check if phase module exists
|
||||
phase_module_path = os.path.join(PHASES_DIR, f"{phase}.py")
|
||||
if not os.path.exists(phase_module_path):
|
||||
return {"success": False, "error": f"Phase module not found: {phase}"}
|
||||
|
||||
# Import and execute phase module
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location(f"phase_{phase}", phase_module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Execute phase
|
||||
result = module.execute(sm)
|
||||
|
||||
# Log to trajectory
|
||||
sm.log_trajectory(f"phase_{phase}", {"result": result})
|
||||
|
||||
# Advance to next phase if successful
|
||||
if result.get("success"):
|
||||
sm.advance_phase()
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
sm.log_trajectory(f"phase_{phase}_error", {"error": str(e)})
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: build_mode_plugin.py [start|status|resume|execute|abort]")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd == "start":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: build_mode_plugin.py start <description>")
|
||||
sys.exit(1)
|
||||
description = " ".join(sys.argv[2:])
|
||||
build_id = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
sm = BuildStateMachine(build_id)
|
||||
checkpoint = sm.load_checkpoint()
|
||||
checkpoint["description"] = description
|
||||
sm.save_checkpoint(checkpoint)
|
||||
sm.log_to_ledger({"action": "build_started", "description": description})
|
||||
print(f"✅ Build {build_id} started")
|
||||
print(f" Description: {description}")
|
||||
print(f" Checkpoint: {sm.checkpoint_path}")
|
||||
|
||||
elif cmd == "status":
|
||||
if len(sys.argv) < 3:
|
||||
# List all builds
|
||||
if os.path.exists(BUILDS_DIR):
|
||||
builds = os.listdir(BUILDS_DIR)
|
||||
if builds:
|
||||
print(f"Builds: {len(builds)}")
|
||||
for build_id in sorted(builds):
|
||||
try:
|
||||
sm = BuildStateMachine(build_id)
|
||||
status = sm.get_status()
|
||||
print(
|
||||
f" {build_id}: {status['description']} ({status['current_phase']}: {status['phase_status']})"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print("No builds found.")
|
||||
else:
|
||||
print("No builds found.")
|
||||
else:
|
||||
build_id = sys.argv[2]
|
||||
try:
|
||||
sm = BuildStateMachine(build_id)
|
||||
status = sm.get_status()
|
||||
print(f"Build: {status['build_id']}")
|
||||
print(f" Description: {status['description']}")
|
||||
print(f" Phase: {status['current_phase']} ({status['phase_status']})")
|
||||
print(f" Batches: {status['batches']}")
|
||||
print(f" Corrections: {status['corrections']}")
|
||||
print(f" Worktree: {status['worktree']}")
|
||||
print(f" Complexity: {status['complexity_score']}")
|
||||
print(f" Estimated steps: {status['estimated_steps']}")
|
||||
except FileNotFoundError:
|
||||
print(f"Build {build_id} not found")
|
||||
|
||||
elif cmd == "resume":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: build_mode_plugin.py resume <build_id>")
|
||||
sys.exit(1)
|
||||
build_id = sys.argv[2]
|
||||
try:
|
||||
sm = BuildStateMachine(build_id)
|
||||
if sm.can_resume():
|
||||
resume_point = sm.get_resume_point()
|
||||
print(f"Resuming build {build_id} from phase: {resume_point}")
|
||||
# Execute the current phase
|
||||
result = execute_phase(build_id)
|
||||
print(f"Result: {result}")
|
||||
else:
|
||||
print(f"Build {build_id} cannot be resumed (all phases complete)")
|
||||
except FileNotFoundError:
|
||||
print(f"Build {build_id} not found")
|
||||
|
||||
elif cmd == "execute":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: build_mode_plugin.py execute <build_id> [phase]")
|
||||
sys.exit(1)
|
||||
build_id = sys.argv[2]
|
||||
phase = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
result = execute_phase(build_id, phase)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
elif cmd == "abort":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: build_mode_plugin.py abort <build_id>")
|
||||
sys.exit(1)
|
||||
build_id = sys.argv[2]
|
||||
try:
|
||||
sm = BuildStateMachine(build_id)
|
||||
checkpoint = sm.load_checkpoint()
|
||||
worktree = checkpoint.get("worktree_path")
|
||||
if worktree:
|
||||
subprocess.run(
|
||||
["git", "worktree", "remove", worktree, "--force"],
|
||||
cwd=REPO_DIR,
|
||||
capture_output=True,
|
||||
)
|
||||
sm.log_to_ledger({"action": "build_aborted"})
|
||||
print(f"✅ Build {build_id} aborted")
|
||||
except FileNotFoundError:
|
||||
print(f"Build {build_id} not found")
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assess phase — Complexity scoring and codebase impact estimation.
|
||||
|
||||
This is the first phase. It scores the user's request to determine:
|
||||
- If build mode is needed (score 8+)
|
||||
- How many steps are estimated
|
||||
- What files will be affected
|
||||
|
||||
Output: Updates checkpoint with complexity_score, estimated_steps, description.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, Any
|
||||
|
||||
# Add parent to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
|
||||
|
||||
|
||||
def execute(sm) -> Dict[str, Any]:
|
||||
"""Execute the assess phase."""
|
||||
checkpoint = sm.load_checkpoint()
|
||||
description = checkpoint.get("description", "")
|
||||
|
||||
if not description:
|
||||
return {"success": False, "error": "No description provided"}
|
||||
|
||||
# Import complexity scorer
|
||||
try:
|
||||
from rune.complexity_scorer import score_request
|
||||
|
||||
breakdown = score_request(description)
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Complexity scorer failed: {e}"}
|
||||
|
||||
# Update checkpoint
|
||||
checkpoint["complexity_score"] = breakdown.total
|
||||
checkpoint["estimated_steps"] = _estimate_steps(description)
|
||||
checkpoint["assess_signals"] = breakdown.signals
|
||||
checkpoint["assess_mode"] = breakdown.mode
|
||||
checkpoint["assess_confidence"] = breakdown.confidence
|
||||
sm.save_checkpoint(checkpoint)
|
||||
|
||||
# Log to ledger
|
||||
sm.log_to_ledger(
|
||||
{
|
||||
"action": "assess_complete",
|
||||
"complexity_score": breakdown.total,
|
||||
"estimated_steps": checkpoint["estimated_steps"],
|
||||
"mode": breakdown.mode,
|
||||
"confidence": breakdown.confidence,
|
||||
"signals": breakdown.signals,
|
||||
}
|
||||
)
|
||||
|
||||
# Check if build mode is needed
|
||||
if breakdown.total < 8:
|
||||
return {
|
||||
"success": True,
|
||||
"build_mode_needed": False,
|
||||
"complexity_score": breakdown.total,
|
||||
"estimated_steps": checkpoint["estimated_steps"],
|
||||
"mode": breakdown.mode,
|
||||
"reason": f"Score {breakdown.total} < 8, task mode recommended",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"build_mode_needed": True,
|
||||
"complexity_score": breakdown.total,
|
||||
"estimated_steps": checkpoint["estimated_steps"],
|
||||
"mode": breakdown.mode,
|
||||
"confidence": breakdown.confidence,
|
||||
"signals": breakdown.signals,
|
||||
}
|
||||
|
||||
|
||||
def _estimate_steps(description: str) -> int:
|
||||
"""Estimate number of steps from description."""
|
||||
steps = 0
|
||||
desc_lower = description.lower()
|
||||
|
||||
# Each distinct verb ≈ 1-2 steps
|
||||
verbs = [
|
||||
"add",
|
||||
"create",
|
||||
"build",
|
||||
"implement",
|
||||
"update",
|
||||
"fix",
|
||||
"refactor",
|
||||
"migrate",
|
||||
"test",
|
||||
"deploy",
|
||||
"configure",
|
||||
"integrate",
|
||||
"design",
|
||||
]
|
||||
verb_count = sum(1 for v in verbs if v in desc_lower)
|
||||
steps += verb_count * 2
|
||||
|
||||
# Each "and" or "then" ≈ 1 step
|
||||
conjunctions = ["and", "then", "also", "plus"]
|
||||
conjunction_count = sum(1 for c in conjunctions if c in desc_lower)
|
||||
steps += conjunction_count
|
||||
|
||||
# Each file reference ≈ 1 step
|
||||
import re
|
||||
|
||||
file_refs = re.findall(r"\b\w+\.\w+\b", description)
|
||||
steps += len(file_refs)
|
||||
|
||||
# Minimum 3 steps for build mode
|
||||
return max(steps, 3)
|
||||
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user