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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flockwork = l2.inrepo:main
@@ -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
@@ -0,0 +1,5 @@
1
+ """flockwork lane — the git-refs coordination substrate.
2
+
3
+ The CLI lives in l2/inrepo.py; the opt-in observer in l2/metrics.py;
4
+ the verdict-count gate in l2/gates.py; the ref schemas in l2/refschema.py.
5
+ """
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:]))