flockwork 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flockwork-0.1.0.dist-info/METADATA +130 -0
- flockwork-0.1.0.dist-info/RECORD +11 -0
- flockwork-0.1.0.dist-info/WHEEL +5 -0
- flockwork-0.1.0.dist-info/entry_points.txt +2 -0
- flockwork-0.1.0.dist-info/licenses/LICENSE +162 -0
- flockwork-0.1.0.dist-info/top_level.txt +1 -0
- l2/__init__.py +5 -0
- l2/gates.py +325 -0
- l2/inrepo.py +2091 -0
- l2/metrics.py +373 -0
- l2/refschema.py +108 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flockwork
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A worker swarm whose entire coordination substrate is git itself.
|
|
5
|
+
Author: jlam
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# flockwork — the swarm that runs like clockwork
|
|
17
|
+
|
|
18
|
+
**flockwork** is a worker swarm whose **entire coordination substrate is
|
|
19
|
+
git itself**: no server, no database, no queue daemon — a shared origin's
|
|
20
|
+
refs under `refs/swarm/` carry task briefs, claims, returns, and
|
|
21
|
+
verdicts, and every guarantee (exactly-once work, honest outcomes,
|
|
22
|
+
bounded retries) reduces to a git property you can verify with
|
|
23
|
+
`ls-remote` and `cat-file`. This repo doubles as its own substrate
|
|
24
|
+
(**D-INREPO**): the machinery lives in `l2/inrepo.py`, and workers
|
|
25
|
+
coordinate through refs on the project's own origin. (The code, CLI, and
|
|
26
|
+
repo internals keep the working name `swarmo` — same product.)
|
|
27
|
+
|
|
28
|
+
> **New to swarms, git refs, or both?** `docs/00-ORIENTATION.md` is the
|
|
29
|
+
> zero-context orientation: the project in 15 sentences, how the pieces
|
|
30
|
+
> relate, which doc to open for which job, what a verdict means, and a
|
|
31
|
+
> full glossary.
|
|
32
|
+
|
|
33
|
+
Run it yourself in five minutes: `QUICKSTART.md` (scratch board, zero
|
|
34
|
+
model spend).
|
|
35
|
+
|
|
36
|
+
## The refs model (one diagram)
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
origin (any git remote — a bare repo; e.g. a local bare repo under /tmp)
|
|
40
|
+
├── refs/heads/main the product: landed fixes
|
|
41
|
+
└── refs/swarm/* the coordination lane
|
|
42
|
+
├── specs/<task> the brief operator seeds it (root commit,
|
|
43
|
+
│ body carries the task + `verify:` line)
|
|
44
|
+
├── claims/<task> the lease create-once CAS push — exactly one
|
|
45
|
+
│ worker wins; duplicates are rejected
|
|
46
|
+
├── tasks/<task> the return points at the fix, on main's lineage
|
|
47
|
+
├── verdicts/<task> the outcome root commit: task/attempt/fixed/host/rc
|
|
48
|
+
└── archive/<kind>/<task>@<att> dead attempts, preserved (never deleted)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Claim and verdict commits are ROOT commits, so their objects never
|
|
52
|
+
leak into consumer clones or fetches; the whole lane is evictable with
|
|
53
|
+
a one-transaction namespace delete.
|
|
54
|
+
|
|
55
|
+
## The five verbs
|
|
56
|
+
|
|
57
|
+
1. **seed** — the operator declares a task: `python3 l2/inrepo.py seed
|
|
58
|
+
spec.json` pushes `refs/swarm/specs/<task>` carrying the brief; a
|
|
59
|
+
`verify:` line in the brief is the task's oracle.
|
|
60
|
+
2. **claim** — a worker leases a task: a create-once CAS push
|
|
61
|
+
(`push --force-with-lease=<ref>:`) to `refs/swarm/claims/<task>`.
|
|
62
|
+
Git itself rejects the second claim — exactly-once is not a lock
|
|
63
|
+
file, it is the ref database.
|
|
64
|
+
3. **work** — the claim winner clones the origin, dispatches the brief
|
|
65
|
+
to the model, and the `verify:` line (or host pytest) judges the
|
|
66
|
+
tree. A pass lands the fix on main and points the return ref at it;
|
|
67
|
+
a fail touches nothing but the verdict.
|
|
68
|
+
4. **verdict** — the attempt closes as a root commit at
|
|
69
|
+
`refs/swarm/verdicts/<task>`: `fixed: true|false`, host, dispatch
|
|
70
|
+
and oracle exit codes. The refs alone must separate environmental
|
|
71
|
+
deaths from honest merit failures.
|
|
72
|
+
5. **evict** — `sweep`/`reconcile` archive dead attempts to
|
|
73
|
+
`refs/swarm/archive/<kind>/<task>@<att>` and free the live refs in
|
|
74
|
+
one atomic transaction, so the task re-enters the queue; eviction
|
|
75
|
+
is bounded (one heir per death, then a final honest `fixed:false`).
|
|
76
|
+
|
|
77
|
+
## The honesty laws (what keeps the lane truthful)
|
|
78
|
+
|
|
79
|
+
Claim and verdict commits are ROOT commits so their objects never leak
|
|
80
|
+
into consumer fetches, and eviction is a one-transaction namespace
|
|
81
|
+
delete. Four honesty laws keep the lane truthful. **Claim-loss
|
|
82
|
+
honesty:** a failed claim is classified — a lost race (`! [rejected]
|
|
83
|
+
(stale info)`) is healthy contention to re-scan, a structurally broken
|
|
84
|
+
origin is reported — and bounded by `SWARM_WORKER_MAX_FAILS` so a sick
|
|
85
|
+
host exits instead of spinning. **Law-freshness gate:** each worker
|
|
86
|
+
refuses to claim when its running `l2/inrepo.py` blob differs from
|
|
87
|
+
origin main's published copy, so stale code never writes to the
|
|
88
|
+
substrate. **Environmental honesty:** a dispatch death (timeout, or
|
|
89
|
+
failure with an empty tree) is never recorded as a fix — the attempt is
|
|
90
|
+
archived and requeued to one heir, then closed with a final honest
|
|
91
|
+
`fixed:false`. **Main-push honesty:** a fix is a fix only if its commit
|
|
92
|
+
actually lands on main, else the task requeues under the same bounded
|
|
93
|
+
machinery — and a change that fails its oracle never lands at all: a
|
|
94
|
+
merit failure publishes only its final honest verdict, never to main.
|
|
95
|
+
|
|
96
|
+
**No-default-origin law:** there is **no** default `SWARM_ORIGIN`. Every
|
|
97
|
+
origin resolution is explicit-arg > `SWARM_ORIGIN` at call time > the
|
|
98
|
+
import-time `ORIGIN` > a typed `OriginUnsetError` naming the fix. A
|
|
99
|
+
write or read leg with no origin anywhere refuses *before* any git/ssh
|
|
100
|
+
contact, so a forgotten config can never push into a production board.
|
|
101
|
+
|
|
102
|
+
`python3 l2/inrepo.py audit` is the board's health instrument
|
|
103
|
+
(`h1_pass`: every live claim has a matching return + verdict);
|
|
104
|
+
`reconcile` is the TTL backstop for stale claims; `correction-graph`
|
|
105
|
+
reconstructs the multi-model attempt graph from refs alone.
|
|
106
|
+
|
|
107
|
+
## Observability (opt-in, zero wire change)
|
|
108
|
+
|
|
109
|
+
- `FLOCKWORK_METRICS=1` turns on a JSONL observer over the lane's own
|
|
110
|
+
results; `python3 -m l2.metrics [files]` summarizes counts,
|
|
111
|
+
reject-rate, and claim→verdict latency. `--dora` adds the DORA five
|
|
112
|
+
over the same stream.
|
|
113
|
+
- `FLOCKWORK_KEEP_TREE=1` preserves a merit-failed attempt's tree so a
|
|
114
|
+
failure is auditable.
|
|
115
|
+
|
|
116
|
+
## Layout
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
l2/inrepo.py the lane law: seed/claim/work/verdict/evict + review/gate + audit
|
|
120
|
+
l2/metrics.py the opt-in JSONL observer + DORA judge
|
|
121
|
+
l2/gates.py the verdict-count gate (n-of-m reviewer agreement)
|
|
122
|
+
l2/refschema.py minimal required-field schemas; loud typed refusals
|
|
123
|
+
tests/ the suite (hermetic: scratch boards, no network, no default origin)
|
|
124
|
+
demo/ the scripted end-to-end (owner_test.sh) + the raw CAS demo
|
|
125
|
+
docs/ 00-ORIENTATION (start here), GATES, SCRATCH-BOARDS
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
Apache-2.0. See `LICENSE`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
flockwork-0.1.0.dist-info/licenses/LICENSE,sha256=noRx8qE5pk5Hg2SX-f9BIaaL8Vq5qDK5zb6r8ray45g,8615
|
|
2
|
+
l2/__init__.py,sha256=hgSLVWfzYABuapdnJBmIriwZFcIsoUB_JtBd0WdBcOQ,208
|
|
3
|
+
l2/gates.py,sha256=-MbcD-NiN9L4eTZIH5dkGY-rcbGd1UZoMRjqyYFR_jQ,12716
|
|
4
|
+
l2/inrepo.py,sha256=z3mwX3-Ru2y-PM_biw-TD9H8aTD04CL5D-rPvhe3jZo,88863
|
|
5
|
+
l2/metrics.py,sha256=ahxnPqeyeeC49s5vh2jXV4wY6x4mBbdk6rUrd-EuwUs,14515
|
|
6
|
+
l2/refschema.py,sha256=2CcMygaT26R6jUahChVrDFBx5IO5JAOkLzToq0LR2mw,4653
|
|
7
|
+
flockwork-0.1.0.dist-info/METADATA,sha256=r0pLhbaIY2ycQFheY-3OfmRoqTT-TjFycyFJQbZkf64,6448
|
|
8
|
+
flockwork-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
flockwork-0.1.0.dist-info/entry_points.txt,sha256=FjyoKj09jziAHjdNwW9mzuvr21v-ME12lvHX9LNxLqM,45
|
|
10
|
+
flockwork-0.1.0.dist-info/top_level.txt,sha256=Wa1GMFfGQKaWYK7qyOSbr7jav5_aJkEpJloKgx66FUg,3
|
|
11
|
+
flockwork-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by the
|
|
14
|
+
copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the legal entity that controls or is
|
|
17
|
+
controlled by such Legal Entity and any entities that control or are
|
|
18
|
+
controlled by such Legal Entity, or are under common control with such Legal
|
|
19
|
+
Entity. For the purposes of this definition, "control" means (i) the power,
|
|
20
|
+
direct or indirect, to cause the direction or management of such Legal
|
|
21
|
+
Entity, whether by contract or otherwise, or (ii) ownership of fifty percent
|
|
22
|
+
(50%) or more of the outstanding shares, or (iii) beneficial ownership.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, whether in Source or
|
|
50
|
+
Object form, made available under the License, as indicated by a
|
|
51
|
+
copyright notice that is included in or attached to the work
|
|
52
|
+
(an example is provided in the Appendix below).
|
|
53
|
+
|
|
54
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
55
|
+
on behalf of whom a Contribution has been received for inclusion in
|
|
56
|
+
the Work by the copyright owner or other entity authorized to grant
|
|
57
|
+
such rights on behalf of the copyright owner.
|
|
58
|
+
|
|
59
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
60
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
61
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
62
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
63
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
64
|
+
Work and such Derivative Works in Source or Object form.
|
|
65
|
+
|
|
66
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
(except as stated in this section) patent license to make, have made,
|
|
70
|
+
use, offer to sell, sell, import, and otherwise transfer the
|
|
71
|
+
Work, where such license applies to patent claims infringed by
|
|
72
|
+
the Work or any Contribution incorporated in the Work.
|
|
73
|
+
|
|
74
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
75
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
76
|
+
modifications, and in Source or Object form, provided that You
|
|
77
|
+
meet the following conditions:
|
|
78
|
+
|
|
79
|
+
(a) You must give any other recipients of the Work or
|
|
80
|
+
Derivative Works a copy of this License; and
|
|
81
|
+
|
|
82
|
+
(b) You must cause any modified files to carry prominent notices
|
|
83
|
+
stating that You changed the files; and
|
|
84
|
+
|
|
85
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
86
|
+
that You distribute, all copyright, patent, trademark, and
|
|
87
|
+
attribution notices from the Source form of the Work,
|
|
88
|
+
excluding those notices that do not pertain to any part of
|
|
89
|
+
the Derivative Works; and
|
|
90
|
+
|
|
91
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
92
|
+
distribution, then any Derivative Works that You distribute must
|
|
93
|
+
include a readable copy of the attribution notices contained
|
|
94
|
+
within such NOTICE file, excluding those notices that do not
|
|
95
|
+
pertain to any part of the Derivative Works.
|
|
96
|
+
|
|
97
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
98
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
99
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
100
|
+
this License, without any additional terms or conditions.
|
|
101
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
102
|
+
the terms of any separate license agreement you may have executed
|
|
103
|
+
with Licensor regarding such Contributions.
|
|
104
|
+
|
|
105
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
106
|
+
names, trademarks, service marks, or product names of the
|
|
107
|
+
Licensor, except as required for reasonable and customary use in
|
|
108
|
+
describing the origin of the Work and reproducing the
|
|
109
|
+
inclusion of the Work in derivative works.
|
|
110
|
+
|
|
111
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
112
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
113
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
114
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
115
|
+
implied, including, without limitation, any warranties or conditions
|
|
116
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
117
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
118
|
+
appropriateness of using or redistributing the Work and assume any
|
|
119
|
+
risks associated with your exercise of permissions under this License.
|
|
120
|
+
|
|
121
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
122
|
+
whether in tort (including negligence), contract, or otherwise, unless
|
|
123
|
+
required by applicable law (such as deliberate and gross
|
|
124
|
+
negligence) or agreed to in writing, shall any Contributor be liable
|
|
125
|
+
to You for damages, including any direct, indirect, special, incidental,
|
|
126
|
+
or consequential damages of any character arising from the use of or
|
|
127
|
+
inability to use the Work (or any other breach of this License), even
|
|
128
|
+
if advised of the possibility of such damages.
|
|
129
|
+
|
|
130
|
+
9. Accepting Warranty or Additional Liability. While redistributing the
|
|
131
|
+
Work or Derivative Works thereof, You may choose to offer, and charge a
|
|
132
|
+
fee for, acceptance of support, warranty, indemnity, or other liability
|
|
133
|
+
obligations and/or rights consistent with this License. However, in
|
|
134
|
+
accepting such obligations, You act on Your own behalf, on Your sole
|
|
135
|
+
responsibility, not on behalf of any Contributor.
|
|
136
|
+
|
|
137
|
+
END OF TERMS AND CONDITIONS
|
|
138
|
+
|
|
139
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
140
|
+
|
|
141
|
+
To apply the Apache License to your work, attach the following
|
|
142
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
143
|
+
replaced with your own identifying information. Don't include
|
|
144
|
+
the brackets! The text should be enclosed in the appropriate
|
|
145
|
+
comment syntax for the file format. We also recommend that a
|
|
146
|
+
file or class name and description of purpose be included in the
|
|
147
|
+
same "printed page" as the copyright notice for easier
|
|
148
|
+
identification within third-party archives.
|
|
149
|
+
|
|
150
|
+
Copyright 2026 jlam
|
|
151
|
+
|
|
152
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
153
|
+
you may not use this file except in compliance with the License.
|
|
154
|
+
You may obtain a copy of the License at
|
|
155
|
+
|
|
156
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
157
|
+
|
|
158
|
+
Unless required by applicable law or agreed to in writing, software
|
|
159
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
160
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
161
|
+
See the License for the specific language governing permissions and
|
|
162
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
l2
|
l2/__init__.py
ADDED
l2/gates.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""flockwork verdict-count gate — n-of-m independent verdicts flip an
|
|
3
|
+
integration ref (docs/GATES.md; INT-081 bridge, INT-085b pilot 1).
|
|
4
|
+
|
|
5
|
+
Design landed through flockwork itself (pilot wave A); this module is
|
|
6
|
+
its implementation. Self-contained on purpose: stdlib only, its own
|
|
7
|
+
git plumbing, no l2 imports — the gate must be liftable to any lane
|
|
8
|
+
without carrying the wire's law file (the lawgate lone-law lesson).
|
|
9
|
+
|
|
10
|
+
Substrate laws it reuses, never redefines:
|
|
11
|
+
- create-once CAS: `git push --force-with-lease=<ref>:` (empty lease
|
|
12
|
+
base) — the ref database decides races, exactly like claim_detail.
|
|
13
|
+
- the @-law: per-instance refs are `<base>/<task>@<instance>` (the
|
|
14
|
+
heirs_count archive shape); the subpath form is illegal beside a
|
|
15
|
+
live leaf ref (git D/F conflict).
|
|
16
|
+
- JSON-event prints: one json.dumps line per operation, like the kit.
|
|
17
|
+
|
|
18
|
+
Verdict body schema (docs/GATES.md §2 — the schema IS the instruction;
|
|
19
|
+
a body failing any check is not a verdict and is never counted):
|
|
20
|
+
|
|
21
|
+
review-verdict
|
|
22
|
+
task: <task>
|
|
23
|
+
reviewer: <reviewer>
|
|
24
|
+
outcome: agree|veto
|
|
25
|
+
evidence: <a git ref the reviewer examined>
|
|
26
|
+
|
|
27
|
+
The gate reads n/m from the task's spec body (`n: <int>` / `m: <int>`
|
|
28
|
+
lines), counts valid reviewer verdicts under
|
|
29
|
+
refs/swarm/verdicts/<task>@*, and fires iff count(agree)==n AND
|
|
30
|
+
count(veto)==0 AND every counted verdict's evidence ref resolves. The
|
|
31
|
+
flip writes refs/swarm/integrated/<task> create-once; an existing flip
|
|
32
|
+
makes any later gate run an honest idempotent no-op.
|
|
33
|
+
"""
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import refschema
|
|
41
|
+
except ImportError: # loaded by path (tests, drivers): resolve the sibling
|
|
42
|
+
try:
|
|
43
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
44
|
+
import refschema
|
|
45
|
+
except ImportError:
|
|
46
|
+
# staged alone (the lawgate lone-law lesson): the gate keeps its
|
|
47
|
+
# own inline §2 checks below — same refusals, no module typing.
|
|
48
|
+
refschema = None
|
|
49
|
+
|
|
50
|
+
LAW = "docs/GATES.md"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sh(cmd, cwd=None, inp=None, timeout=120):
|
|
54
|
+
return subprocess.run(cmd, cwd=cwd, input=inp, capture_output=True,
|
|
55
|
+
text=True, timeout=timeout)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def ok(r):
|
|
59
|
+
return r.returncode == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def git(*args, cwd=None, inp=None):
|
|
63
|
+
return sh(["git", *args], cwd=cwd, inp=inp)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OriginUnsetError(RuntimeError):
|
|
67
|
+
"""No origin to talk to: no explicit orig and SWARM_ORIGIN unset.
|
|
68
|
+
A config error, never a silent write (WQ-093, 2026-10-03). Kept
|
|
69
|
+
name/shape-identical to l2/inrepo.py's — this module is
|
|
70
|
+
deliberately self-contained (no l2 imports), so the two classes
|
|
71
|
+
are textual twins by law, not by import."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def origin():
|
|
75
|
+
"""The gate's board: explicit orig > SWARM_ORIGIN. The old default
|
|
76
|
+
(the literal remote name "origin" — whatever the cwd clone points
|
|
77
|
+
at) was a silent write target of the same footgun family as
|
|
78
|
+
inrepo's production default; there is no default now."""
|
|
79
|
+
o = os.environ.get("SWARM_ORIGIN")
|
|
80
|
+
if not o:
|
|
81
|
+
raise OriginUnsetError(
|
|
82
|
+
"no origin: export SWARM_ORIGIN=<board-url> (or pass "
|
|
83
|
+
"orig=) — there is no default origin (WQ-093, 2026-10-03: "
|
|
84
|
+
"the old 'origin' remote-name default pushed gate writes "
|
|
85
|
+
"to whatever the cwd clone points at)"
|
|
86
|
+
)
|
|
87
|
+
return o
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def emit(event):
|
|
91
|
+
print(json.dumps(event), flush=True)
|
|
92
|
+
return event
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def commit_tree(*args, cwd=None):
|
|
96
|
+
"""Empty-tree commit carrying a body; its sha is pushable from cwd
|
|
97
|
+
(the object store lives in the repo the process runs in — the
|
|
98
|
+
test_claim_loss lesson)."""
|
|
99
|
+
t = git("hash-object", "-t", "tree", "/dev/null", cwd=cwd)
|
|
100
|
+
if not ok(t):
|
|
101
|
+
raise RuntimeError(f"tree: {t.stderr.strip()[:120]}")
|
|
102
|
+
c = git("commit-tree", t.stdout.strip(), "-m", "\n".join(args),
|
|
103
|
+
cwd=cwd)
|
|
104
|
+
if not ok(c):
|
|
105
|
+
raise RuntimeError(f"commit-tree: {c.stderr.strip()[:120]}")
|
|
106
|
+
return c.stdout.strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def push_sha_ref(sha, ref, orig, cwd=None):
|
|
110
|
+
"""Create-once CAS push. Returns (pushed, rejected, stderr)."""
|
|
111
|
+
r = git("push", orig, f"--force-with-lease={ref}:", f"{sha}:{ref}",
|
|
112
|
+
cwd=cwd)
|
|
113
|
+
return ok(r), not ok(r), (r.stderr or "")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ref_exists(orig, ref):
|
|
117
|
+
r = git("ls-remote", "--exit-code", orig, ref)
|
|
118
|
+
return ok(r)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------- verdicts
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check_name(kind, name):
|
|
125
|
+
"""Refname safety: '@' is the archive-marker separator (an @-named
|
|
126
|
+
instance would collide with the @-law parse), '/' would make the
|
|
127
|
+
instance a path (and a task a namespace)."""
|
|
128
|
+
if not name or "@" in name or "/" in name:
|
|
129
|
+
return f"{kind} name must be nonempty and free of '@' and '/': {name!r}"
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_review_verdict(body, task, reviewer):
|
|
134
|
+
"""docs/GATES.md §2 schema check. Returns (fields, error). The
|
|
135
|
+
schema's definition of record is l2/refschema.py (the schema IS the
|
|
136
|
+
instruction, WQ-054); this delegation keeps one implementation, the
|
|
137
|
+
inline body below is the staged-alone fallback."""
|
|
138
|
+
if refschema is not None:
|
|
139
|
+
fields, errs = refschema.validate_review_verdict(body, task,
|
|
140
|
+
reviewer)
|
|
141
|
+
return fields, ("; ".join(errs) if errs else None)
|
|
142
|
+
lines = body.strip().splitlines()
|
|
143
|
+
if not lines or lines[0].strip() != "review-verdict":
|
|
144
|
+
return None, "not a review-verdict object"
|
|
145
|
+
fields = {}
|
|
146
|
+
for ln in lines[1:]:
|
|
147
|
+
if ": " in ln:
|
|
148
|
+
k, v = ln.split(": ", 1)
|
|
149
|
+
fields[k.strip()] = v.strip()
|
|
150
|
+
if fields.get("task") != task:
|
|
151
|
+
return None, "task field mismatch"
|
|
152
|
+
if fields.get("reviewer") != reviewer:
|
|
153
|
+
return None, "reviewer field mismatch"
|
|
154
|
+
if fields.get("outcome") not in ("agree", "veto"):
|
|
155
|
+
return None, "outcome not agree|veto"
|
|
156
|
+
if not fields.get("evidence"):
|
|
157
|
+
return None, "evidence missing"
|
|
158
|
+
return fields, None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def review_verdict(task, reviewer, outcome, evidence, orig=None, cwd=None):
|
|
162
|
+
"""Create-once reviewer verdict at refs/swarm/verdicts/<task>@<reviewer>."""
|
|
163
|
+
orig = orig or origin()
|
|
164
|
+
err = _check_name("task", task) or _check_name("reviewer", reviewer)
|
|
165
|
+
if err:
|
|
166
|
+
return emit({"event": "review_verdict", "task": task,
|
|
167
|
+
"reviewer": reviewer, "pushed": False,
|
|
168
|
+
"refused": True, "reason": err})
|
|
169
|
+
if outcome not in ("agree", "veto"):
|
|
170
|
+
return emit({"event": "review_verdict", "task": task,
|
|
171
|
+
"reviewer": reviewer, "pushed": False,
|
|
172
|
+
"refused": True,
|
|
173
|
+
"reason": "outcome must be agree|veto"})
|
|
174
|
+
ref = f"refs/swarm/verdicts/{task}@{reviewer}"
|
|
175
|
+
body = (f"review-verdict\ntask: {task}\nreviewer: {reviewer}\n"
|
|
176
|
+
f"outcome: {outcome}\nevidence: {evidence}\n")
|
|
177
|
+
sha = commit_tree(body, cwd=cwd)
|
|
178
|
+
pushed, rejected, stderr = push_sha_ref(sha, ref, orig, cwd=cwd)
|
|
179
|
+
return emit({
|
|
180
|
+
"event": "review_verdict", "task": task, "reviewer": reviewer,
|
|
181
|
+
"outcome": outcome, "ref": ref, "sha": sha[:12],
|
|
182
|
+
"pushed": pushed, "rejected": rejected and not pushed,
|
|
183
|
+
"reason": None if pushed else stderr.strip()[:200],
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# -------------------------------------------------------------------- gate
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _spec_nm(orig, task):
|
|
191
|
+
"""n and m from the spec body's `n: <int>` / `m: <int>` lines."""
|
|
192
|
+
fr = git("fetch", "-q", orig, f"refs/swarm/specs/{task}")
|
|
193
|
+
if not ok(fr):
|
|
194
|
+
raise RuntimeError(f"spec fetch: {fr.stderr.strip()[:160]}")
|
|
195
|
+
body = git("log", "-1", "--format=%B", "FETCH_HEAD").stdout
|
|
196
|
+
n = m = None
|
|
197
|
+
for ln in body.splitlines():
|
|
198
|
+
s = ln.strip()
|
|
199
|
+
if s.startswith("n:"):
|
|
200
|
+
n = int(s[2:].strip())
|
|
201
|
+
elif s.startswith("m:"):
|
|
202
|
+
m = int(s[2:].strip())
|
|
203
|
+
if n is None or m is None:
|
|
204
|
+
raise RuntimeError(f"spec {task} carries no n:/m: lines")
|
|
205
|
+
return n, m
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _reviewer_refs(orig, task):
|
|
209
|
+
r = git("ls-remote", orig, f"refs/swarm/verdicts/{task}@*")
|
|
210
|
+
if not ok(r):
|
|
211
|
+
raise RuntimeError(f"board read: {r.stderr.strip()[:160]}")
|
|
212
|
+
out = []
|
|
213
|
+
for ln in r.stdout.splitlines():
|
|
214
|
+
if ln.strip():
|
|
215
|
+
sha, ref = ln.split()
|
|
216
|
+
out.append((ref, sha))
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _read_ref_body(orig, sha_or_ref):
|
|
221
|
+
"""Commit message body of a sha (the house verdict-read: %B, never
|
|
222
|
+
raw cat-file — the commit header is not the schema)."""
|
|
223
|
+
r = git("log", "-1", "--format=%B", sha_or_ref)
|
|
224
|
+
return r.stdout if ok(r) else None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def verdicts(orig, task):
|
|
228
|
+
"""Parsed reviewer verdicts for task: (reviewer, outcome, evidence,
|
|
229
|
+
invalid_reason or None, sha). Applies the full §2 schema."""
|
|
230
|
+
out = []
|
|
231
|
+
for ref, sha in _reviewer_refs(orig, task):
|
|
232
|
+
reviewer = ref.rsplit("@", 1)[1]
|
|
233
|
+
body = _read_ref_body(orig, sha)
|
|
234
|
+
fields, err = (None, f"unreadable object {sha[:12]}")
|
|
235
|
+
if body is not None:
|
|
236
|
+
fields, err = parse_review_verdict(body, task, reviewer)
|
|
237
|
+
if fields is None:
|
|
238
|
+
out.append((reviewer, None, None, err, sha))
|
|
239
|
+
else:
|
|
240
|
+
out.append((reviewer, fields["outcome"], fields["evidence"],
|
|
241
|
+
None, sha))
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def flip(task, agreed, evidence=None, n=None, m=None, orig=None, cwd=None):
|
|
246
|
+
"""Create-once CAS flip of refs/swarm/integrated/<task>. Returns the
|
|
247
|
+
push result dict (property 4's referee). evidence/n/m resolve from
|
|
248
|
+
the board when not given (first agreeing verdict's evidence ref;
|
|
249
|
+
the spec's n:/m: lines)."""
|
|
250
|
+
orig = orig or origin()
|
|
251
|
+
if n is None or m is None:
|
|
252
|
+
n, m = _spec_nm(orig, task)
|
|
253
|
+
if evidence is None:
|
|
254
|
+
vs = verdicts(orig, task)
|
|
255
|
+
agree = [v for v in vs if v[1] == "agree"]
|
|
256
|
+
if not agree:
|
|
257
|
+
raise RuntimeError("flip with no agreeing verdict to cite")
|
|
258
|
+
evidence = agree[0][2]
|
|
259
|
+
ref = f"refs/swarm/integrated/{task}"
|
|
260
|
+
body = (f"integrated\ntask: {task}\nn: {n}\nm: {m}\n"
|
|
261
|
+
f"agreed: {' '.join(agreed)}\nevidence: {evidence}\n")
|
|
262
|
+
sha = commit_tree(body, cwd=cwd)
|
|
263
|
+
pushed, rejected, stderr = push_sha_ref(sha, ref, orig, cwd=cwd)
|
|
264
|
+
return {"event": "flip", "task": task, "ref": ref, "sha": sha[:12],
|
|
265
|
+
"pushed": pushed,
|
|
266
|
+
"rejected": rejected and not pushed,
|
|
267
|
+
"reason": None if pushed else stderr.strip()[:200]}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def gate(task, orig=None, cwd=None):
|
|
271
|
+
"""The gate: count, check evidence, fire iff agree==n and veto==0.
|
|
272
|
+
Idempotent: an existing integration ref is an honest no-op."""
|
|
273
|
+
orig = orig or origin()
|
|
274
|
+
intref = f"refs/swarm/integrated/{task}"
|
|
275
|
+
if ref_exists(orig, intref):
|
|
276
|
+
return emit({"event": "gate", "task": task,
|
|
277
|
+
"already_integrated": True, "fired": False,
|
|
278
|
+
"flipped": False})
|
|
279
|
+
n, m = _spec_nm(orig, task)
|
|
280
|
+
vs = verdicts(orig, task)
|
|
281
|
+
agree = [v for v in vs if v[1] == "agree"]
|
|
282
|
+
veto = [v for v in vs if v[1] == "veto"]
|
|
283
|
+
invalid = [v for v in vs if v[1] is None]
|
|
284
|
+
missing = [v for v in agree + veto
|
|
285
|
+
if not ref_exists(orig, v[2])]
|
|
286
|
+
fired = len(agree) == n and len(veto) == 0 and not missing
|
|
287
|
+
ev = {"event": "gate", "task": task, "n": n, "m": m,
|
|
288
|
+
"agree": len(agree), "veto": len(veto),
|
|
289
|
+
"invalid": len(invalid), "evidence_missing": len(missing),
|
|
290
|
+
"fired": fired, "flipped": False,
|
|
291
|
+
"already_integrated": False}
|
|
292
|
+
if invalid:
|
|
293
|
+
# the typed schema refusals, loud in the event (WQ-054): a body
|
|
294
|
+
# that is not a verdict is never counted, and the field it is
|
|
295
|
+
# missing is named. Absent entirely on a clean count — a
|
|
296
|
+
# well-formed board's event gains no keys.
|
|
297
|
+
ev["invalid_reasons"] = sorted({v[3] for v in invalid if v[3]})
|
|
298
|
+
if fired:
|
|
299
|
+
evidence = agree[0][2]
|
|
300
|
+
res = flip(task, [v[0] for v in agree], evidence, n, m,
|
|
301
|
+
orig=orig, cwd=cwd)
|
|
302
|
+
ev["flipped"] = res["pushed"]
|
|
303
|
+
ev["flip"] = res
|
|
304
|
+
return emit(ev)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --------------------------------------------------------------------- CLI
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def main(argv):
|
|
311
|
+
if len(argv) >= 2 and argv[0] == "review":
|
|
312
|
+
task, reviewer, outcome = argv[1], argv[2], argv[3]
|
|
313
|
+
evidence = argv[4] if len(argv) > 4 else f"refs/swarm/tasks/{task}"
|
|
314
|
+
ev = review_verdict(task, reviewer, outcome, evidence)
|
|
315
|
+
return 0 if ev["pushed"] else 1
|
|
316
|
+
if len(argv) >= 2 and argv[0] == "gate":
|
|
317
|
+
ev = gate(argv[1])
|
|
318
|
+
return 0 if (ev["fired"] or ev["already_integrated"]) else 1
|
|
319
|
+
print(f"usage: {sys.argv[0]} review <task> <reviewer> <agree|veto> "
|
|
320
|
+
f"[evidence-ref] | gate <task>", file=sys.stderr)
|
|
321
|
+
return 2
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
sys.exit(main(sys.argv[1:]))
|