@tachikomagundam/abathur 0.2.6 → 0.2.8

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.
Files changed (56) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/readjudicate.js +28 -0
  4. package/dist/commands/retract.js +53 -0
  5. package/dist/commands/tombstone.js +28 -15
  6. package/dist/core/evolve/run-bench.js +7 -1
  7. package/dist/core/evolve/run-rows.js +14 -0
  8. package/dist/core/ledger.js +2 -0
  9. package/dist/core/promote.js +61 -46
  10. package/dist/core/readjudicate.js +247 -0
  11. package/dist/core/retract.js +166 -0
  12. package/dist/core/stats.js +7 -0
  13. package/dist/core/worktree.js +1 -1
  14. package/dist/genomes/toy-smoke/init.mjs +1 -1
  15. package/dist/test/bundle.test.js +1 -1
  16. package/dist/test/fixture-loop.test.js +1 -1
  17. package/dist/test/fixtures-self.js +1 -1
  18. package/dist/test/fixtures-wt.js +1 -1
  19. package/dist/test/friction.test.js +3 -3
  20. package/dist/test/graft.test.js +1 -1
  21. package/dist/test/historian-run-scenario.test.js +2 -2
  22. package/dist/test/promote.test.js +222 -5
  23. package/dist/test/readjudicate.test.js +212 -0
  24. package/dist/test/reflect.test.js +1 -1
  25. package/dist/test/repopath-seams.test.js +1 -0
  26. package/dist/test/seam-gates.test.js +6 -0
  27. package/dist/test/self-snapshot.test.js +2 -2
  28. package/dist/test/stats.test.js +17 -0
  29. package/docs/AGENTISM.md +58 -0
  30. package/docs/release/README.md +9 -0
  31. package/docs/release/TEMPLATE.md +10 -0
  32. package/docs/release/v0.2.7.md +7 -0
  33. package/docs/release/v0.2.8.md +8 -0
  34. package/graders/historian/judge-poststage.mjs +8 -3
  35. package/graders/pcb-agent/bench-approver.py +77 -0
  36. package/graders/pcb-agent/grader.mjs +130 -0
  37. package/graders/pcb-agent/mutate.sh +165 -0
  38. package/graders/pcb-agent/r20-bench.sh +27 -0
  39. package/graders/pcb-agent/reset-sandbox.sh +7 -0
  40. package/graders/pcb-agent/run-scenario-r20.sh +77 -0
  41. package/graders/pcb-agent/run-scenario.sh +94 -0
  42. package/graders/pcb-agent/seed/esp32s3-r12-BRIEF.txt +51 -0
  43. package/graders/pcb-agent/seed/spec.json +129 -0
  44. package/graders/pcb-agent/seed/tools/board_author_pcb.py +237 -0
  45. package/graders/pcb-agent/seed/tools/board_author_sch.py +118 -0
  46. package/graders/pcb-agent/seed/tools/board_finalize.sh +23 -0
  47. package/graders/pcb-agent/seed/tools/board_gate.py +69 -0
  48. package/graders/pcb-agent/seed/tools/board_gate_pcb.py +23 -0
  49. package/graders/pcb-agent/seed/tools/board_route.py +324 -0
  50. package/graders/pcb-agent/seed/tools/board_zone_pour.py +312 -0
  51. package/graders/pcb-agent/seed/tools/claims_lint.py +417 -0
  52. package/graders/pcb-agent/seed-r20-arena.sh +54 -0
  53. package/graders/pcb-agent/seed-sandbox.sh +13 -0
  54. package/package.json +1 -1
  55. package/plugin/abathur-command.md +3 -3
  56. package/plugin/abathur.ts +6 -6
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env python3
2
+ # ============================================================================
3
+ # claims_lint.py — RM-26 (round-4) + RM-37 v2 (round-6): mechanical claim
4
+ # verification for reports/attestations/receipts.
5
+ # ----------------------------------------------------------------------------
6
+ # v1 (RM-26): EVERY absolute-path claim that appears with a 64-hex token on the
7
+ # same line (+/-60 chars) must resolve to a file whose RAW sha256 equals the
8
+ # claim; bare path tokens inside backticks on evidence/ledger/receipt context
9
+ # lines must at least exist.
10
+ #
11
+ # v2 (RM-37, RC-2 / incident #4 root cause): cited HISTORY (session/message
12
+ # rows and dispatch/ledger/receipt ids) bypassed v1 because v1 only verified
13
+ # FILE PATHS. An attestation could cite a fabricated ses_/msg_/receipt id and
14
+ # v1 stayed green. In --claims mode v2 additionally requires that every such
15
+ # identifier RESOLVE against live sources:
16
+ # * ses_<id> -> a real row in the `session` table of the authoritative
17
+ # opencode DB opened READ-ONLY (sqlite URI mode=ro).
18
+ # * msg_<id> -> a real row in the `message` table of that DB.
19
+ # * pcb-dispatch-<...>, denied-<...> -> a file whose name matches the id
20
+ # under an allowlisted dispatch/ledger dir.
21
+ # * round-approval-<n>-<UTC> -> a receipt file of that exact name
22
+ # under an allowlisted capabilities dir.
23
+ # Any unresolved identifier -> exit 1 listing every miss. v1 path+sha behaviour
24
+ # is fully preserved and runs in BOTH modes; --report keeps the historical
25
+ # (path/sha-only) surface with no DB dependency (backward compatible, <2s).
26
+ #
27
+ # Usage:
28
+ # python3 tools/claims_lint.py --report FILE [--base DIR] # v1 surface
29
+ # python3 tools/claims_lint.py --claims FILE [--base DIR] [--db PATH] # v1 + ids
30
+ # --db authoritative opencode DB for session/message resolution. Default:
31
+ # $OPENCODE_DB if it exists, else $XDG_DATA_HOME|~/.local/share /opencode/
32
+ # opencode.db. Always opened `mode=ro`. Unreadable/missing DB is a
33
+ # fail-closed ERROR (every ses_/msg_ claim then counts as unresolved).
34
+ #
35
+ # v3 counts extension (round-10, R9-07: orchestrator prose child-count drift —
36
+ # receipts/ledgers are authoritative over prose):
37
+ # python3 tools/claims_lint.py --counts FILE --dir D [--dir D2 ...]
38
+ # [--since YYYY-MM-DDTHH:MM:SSZ]
39
+ # For every line matching a NUMERIC English count claim of the form
40
+ # "<N> children", "<N> dispatched children", "<N> receipts" or
41
+ # "<N> ledger lines" (case-insensitive), N is compared against a
42
+ # mechanically derived count over the explicit --dir set:
43
+ # children / dispatched children -> dispatch RECEIPT files (*.json that do
44
+ # NOT start with "denied-") carrying a non-empty "child_session_id";
45
+ # receipts -> the same receipt files, counted;
46
+ # ledger lines -> ledger files (name starts with
47
+ # "denied-" and ends with ".json").
48
+ # --since (UTC ISO-8601) filters files by modification time >= since
49
+ # (fail-closed: a malformed --since is a hard error). Any mismatch is a
50
+ # lint ERROR listing claimed vs actual per line; exit 1 iff any mismatch.
51
+ # v1/v2 behavior is UNTOUCHED and --claims stays the default full surface;
52
+ # --counts is an independent mode (no path/DB coupling, and the count modes
53
+ # never run the DB resolver).
54
+ # ============================================================================
55
+ import argparse
56
+ import hashlib
57
+ import json
58
+ import os
59
+ import re
60
+ import sqlite3
61
+ import sys
62
+
63
+ # ---- v1 patterns -----------------------------------------------------------
64
+ # ABS_PATH_RE: RM-37 adds the (?!\w) end-guard. Without it the shorter extension
65
+ # alternatives (sh, json) shadow their longer siblings (sha256, jsonl) and truncate
66
+ # a real "...sha256" token to "...sh", producing a false PATH MISSING. The detection
67
+ # semantics (abs path paired with a nearby 64-hex) are otherwise unchanged from RM-26.
68
+ ABS_PATH_RE = re.compile(r"(?<![\w./+-])/[\w][\w./+-]*\.(?:sha256|jsonl|json|patch|diff|yaml|yml|log|txt|md|csv|py|sh|ts)(?!\w)")
69
+ HEX64_RE = re.compile(r"\b[0-9a-f]{64}\b")
70
+ BARE_TICK_RE = re.compile(r"`([\w][\w./+-]*\.(?:json|jsonl|md|txt|py|sh|ts|log|sha256|csv|yaml|yml))`")
71
+ CONTEXT_RE = re.compile(r"evidence|ledger|receipt", re.IGNORECASE)
72
+ HEX_WINDOW = 60
73
+
74
+ # ---- v2 identifier classes (RM-37 step 7) ---------------------------------
75
+ # Each entry: (class-name, regex). Regexes are unanchored; a single line may
76
+ # carry many. The trailing-char classes stop at punctuation/quotes/backticks.
77
+ SESSION_ID_RE = re.compile(r"\bses_[A-Za-z0-9]{20,}")
78
+ MSG_ID_RE = re.compile(r"\bmsg_[A-Za-z0-9]{20,}")
79
+ DISPATCH_ID_RE = re.compile(r"\bpcb-dispatch-[a-z0-9][a-z0-9-]*")
80
+ DENIED_ID_RE = re.compile(r"\bdenied-[a-z0-9][a-z0-9-]*")
81
+ RECEIPT_ID_RE = re.compile(r"\bround-approval-\d+-\d{8}T\d{6}Z")
82
+
83
+ WORKSPACE_ROOT = "/home/lab/workspace"
84
+ # Allowlisted dirs for dispatch/ledger/receipt id resolution (RM-37 step 7).
85
+ DISPATCH_DIRS = [
86
+ os.path.join(WORKSPACE_ROOT, "pcb-control", "dispatches"),
87
+ os.path.join(WORKSPACE_ROOT, "pcb-control", "state", "dispatches"),
88
+ os.path.join(WORKSPACE_ROOT, "pcb-control", "state", "discovery-findings"),
89
+ ]
90
+ RECEIPT_DIRS = [
91
+ os.path.join(WORKSPACE_ROOT, "pcb-control", "state", "capabilities"),
92
+ os.path.join(WORKSPACE_ROOT, "harness", "PCB-Agent", "reports", "recovery", "dispatches"),
93
+ ]
94
+
95
+
96
+ def default_db_path():
97
+ env = os.environ.get("OPENCODE_DB")
98
+ if env and os.path.isfile(env):
99
+ return env
100
+ xdg = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share")
101
+ return os.path.join(xdg, "opencode", "opencode.db")
102
+
103
+
104
+ def sha256_file(path):
105
+ h = hashlib.sha256()
106
+ with open(path, "rb") as f:
107
+ for chunk in iter(lambda: f.read(1 << 20), b""):
108
+ h.update(chunk)
109
+ return h.hexdigest()
110
+
111
+
112
+ class IdResolver:
113
+ """Resolve v2 identifiers against live sources (DB read-only + allowlist)."""
114
+
115
+ def __init__(self, db_path, unverifiable_path=None):
116
+ self.db_path = db_path
117
+ self.unverifiable = {"session": set(), "message": set()}
118
+ self.unverifiable_reason = ""
119
+ if unverifiable_path and os.path.isfile(unverifiable_path):
120
+ with open(unverifiable_path, "r", encoding="utf-8") as f:
121
+ reg = json.load(f)
122
+ self.unverifiable_reason = str(reg.get("reason", ""))
123
+ for kind in ("session", "message"):
124
+ self.unverifiable[kind] = set(reg.get("ids", {}).get(kind, []))
125
+ self._conn = None
126
+ self._db_error = None
127
+ self._dir_index = None
128
+ self._seen = {}
129
+
130
+ # -- DB (mode=ro) ---------------------------------------------------------
131
+ def _open(self):
132
+ if self._conn is not None or self._db_error is not None:
133
+ return self._conn
134
+ try:
135
+ if not os.path.isfile(self.db_path):
136
+ raise OSError("db file not found")
137
+ uri = "file:%s?mode=ro" % self.db_path
138
+ self._conn = sqlite3.connect(uri, uri=True, timeout=10)
139
+ # guard: read-only connection must not be able to write
140
+ self._conn.execute("SELECT 1 FROM sqlite_master LIMIT 1")
141
+ except Exception as e: # fail-closed: a DB error makes ses_/msg_ unresolved
142
+ self._db_error = str(e)
143
+ self._conn = None
144
+ return self._conn
145
+
146
+ def _row_exists(self, table, ident):
147
+ conn = self._open()
148
+ if conn is None:
149
+ return False
150
+ try:
151
+ cur = conn.execute("SELECT 1 FROM %s WHERE id = ? LIMIT 1" % table, (ident,))
152
+ return cur.fetchone() is not None
153
+ except sqlite3.Error:
154
+ return False
155
+
156
+ # -- directory allowlist --------------------------------------------------
157
+ def _index(self):
158
+ if self._dir_index is not None:
159
+ return self._dir_index
160
+ entries = {}
161
+ for d in DISPATCH_DIRS + RECEIPT_DIRS:
162
+ try:
163
+ if os.path.isdir(d):
164
+ entries[d] = os.listdir(d)
165
+ except OSError:
166
+ pass
167
+ self._dir_index = entries
168
+ return entries
169
+
170
+ def _file_id_resolves(self, token, dirs):
171
+ idx = self._index()
172
+ for d in dirs:
173
+ for name in idx.get(d, []):
174
+ # exact id, id.<ext>, or an id embedded in a longer ledger name
175
+ if name == token or name.startswith(token + ".") or token in name:
176
+ return True
177
+ return False
178
+
179
+ def unverifiable_hit(self, kind, token):
180
+ return kind in self.unverifiable and token in self.unverifiable[kind]
181
+
182
+ def resolve(self, kind, token):
183
+ key = (kind, token)
184
+ if key in self._seen:
185
+ return self._seen[key]
186
+ if kind == "session":
187
+ ok = self._row_exists("session", token)
188
+ elif kind == "message":
189
+ ok = self._row_exists("message", token)
190
+ elif kind == "dispatch":
191
+ ok = self._file_id_resolves(token, DISPATCH_DIRS)
192
+ elif kind == "denied":
193
+ # denied-* are ledger files: match under dispatch/ledger dirs too
194
+ ok = self._file_id_resolves(token, DISPATCH_DIRS + RECEIPT_DIRS)
195
+ elif kind == "receipt":
196
+ ok = self._file_id_resolves(token, RECEIPT_DIRS)
197
+ else:
198
+ ok = False
199
+ self._seen[key] = ok
200
+ return ok
201
+
202
+ def close(self):
203
+ if self._conn is not None:
204
+ try:
205
+ self._conn.close()
206
+ except Exception:
207
+ pass
208
+
209
+
210
+ IDENT_CLASSES = [
211
+ ("session", SESSION_ID_RE),
212
+ ("message", MSG_ID_RE),
213
+ ("dispatch", DISPATCH_ID_RE),
214
+ ("denied", DENIED_ID_RE),
215
+ ("receipt", RECEIPT_ID_RE),
216
+ ]
217
+
218
+
219
+ def lint(report_path, base_dir, check_ids=False, db_path=None, unverifiable_path=None):
220
+ failures = 0
221
+ claims = 0
222
+ id_claims = 0
223
+ try:
224
+ with open(report_path, encoding="utf-8", errors="replace") as f:
225
+ text = f.read()
226
+ lines = text.splitlines()
227
+ except OSError as e:
228
+ print("FAIL: report unreadable: %s (%s)" % (report_path, e))
229
+ return 1, 0
230
+ report_dir = os.path.dirname(os.path.abspath(report_path))
231
+
232
+ resolver = IdResolver(db_path, unverifiable_path) if check_ids else None
233
+ db_unavailable_noted = False
234
+ try:
235
+ for no, line in enumerate(lines, 1):
236
+ hexes = list(HEX64_RE.finditer(line))
237
+ for m in ABS_PATH_RE.finditer(line):
238
+ path_tok = m.group(0)
239
+ hm = None
240
+ for hx in hexes:
241
+ if hx.start() - m.end() <= HEX_WINDOW and m.start() - hx.end() <= HEX_WINDOW:
242
+ hm = hx
243
+ break
244
+ if not hm:
245
+ continue
246
+ claims += 1
247
+ want = hm.group(0)
248
+ if not os.path.isfile(path_tok):
249
+ print("FAIL line %d: PATH MISSING: %s (claimed sha %s...)" % (no, path_tok, want[:16]))
250
+ failures += 1
251
+ continue
252
+ try:
253
+ have = sha256_file(path_tok)
254
+ except OSError as e:
255
+ print("FAIL line %d: PATH UNREADABLE: %s (%s)" % (no, path_tok, e))
256
+ failures += 1
257
+ continue
258
+ if have != want:
259
+ print("FAIL line %d: SHA256 MISMATCH: %s: claimed %s... disk %s..." % (no, path_tok, want[:16], have[:16]))
260
+ failures += 1
261
+ else:
262
+ print("OK line %d: %s == %s..." % (no, path_tok, want[:16]))
263
+ if CONTEXT_RE.search(line):
264
+ for m in BARE_TICK_RE.finditer(line):
265
+ rel = m.group(1)
266
+ cands = [os.path.join(report_dir, rel), os.path.join(base_dir, rel), rel]
267
+ if any(os.path.isfile(c) for c in cands):
268
+ continue
269
+ print("FAIL line %d: ARTIFACT MISSING (evidence/ledger/receipt context): %s" % (no, rel))
270
+ failures += 1
271
+
272
+ # ---- v2 identifier resolution (only in --claims mode) ----
273
+ if check_ids:
274
+ for kind, rx in IDENT_CLASSES:
275
+ for im in rx.finditer(line):
276
+ token = im.group(0)
277
+ id_claims += 1
278
+ if resolver.unverifiable_hit(kind, token):
279
+ print("NOTE line %d: %s ID %s is ATTESTED-PURGED (declared-unverifiable registry: %s)"
280
+ % (no, kind, token, resolver.unverifiable_reason))
281
+ continue
282
+ if resolver.resolve(kind, token):
283
+ continue
284
+ if kind in ("session", "message") and not db_unavailable_noted and resolver._db_error:
285
+ print("FAIL: session/message DB unreadable for identifier resolution: %s (%s)"
286
+ % (resolver.db_path, resolver._db_error))
287
+ db_unavailable_noted = True
288
+ print("FAIL line %d: UNRESOLVED %s ID: %s (must resolve against the read-only DB / allowlisted dirs)"
289
+ % (no, kind, token))
290
+ failures += 1
291
+ finally:
292
+ if resolver is not None:
293
+ resolver.close()
294
+
295
+ summary = "claims_lint: %d lines; path+hash claims=%d; failures=%d" % (len(lines), claims, failures)
296
+ if check_ids:
297
+ summary += "; id-claims=%d" % id_claims
298
+ print(summary)
299
+ return failures, claims
300
+
301
+
302
+ # ---- v3 count-claim mode (R9-07) --------------------------------------------
303
+ COUNT_CLAIM_RE = re.compile(
304
+ r"\b(\d+)\s+(dispatched children|children|receipts|ledger[ _-]lines)\b",
305
+ re.IGNORECASE)
306
+
307
+
308
+ def _parse_since(s):
309
+ # strict ISO-8601 UTC instant; anything else -> fail closed (None)
310
+ m = re.match(r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z)$", s or "")
311
+ if not m:
312
+ return None
313
+ import calendar
314
+ try:
315
+ y, mo, d, h, mi, se = (int(g) for g in m.groups()[:6])
316
+ return calendar.timegm((y, mo, d, h, mi, se, 0, 0, 0))
317
+ except (ValueError, OverflowError):
318
+ return None
319
+
320
+
321
+ def scan_counts(dirs, since_iso=None):
322
+ """Mechanically derive (receipts, children, ledger_lines) over --dir set."""
323
+ since = None
324
+ if since_iso is not None:
325
+ since = _parse_since(since_iso)
326
+ if since is None:
327
+ raise ValueError("malformed --since (want YYYY-MM-DDTHH:MM:SSZ UTC): %r" % since_iso)
328
+ receipts = 0
329
+ children = 0
330
+ ledger_lines = 0
331
+ for d in dirs:
332
+ if not os.path.isdir(d):
333
+ continue
334
+ for name in sorted(os.listdir(d)):
335
+ p = os.path.join(d, name)
336
+ if not os.path.isfile(p) or not name.endswith(".json"):
337
+ continue
338
+ if since is not None and os.path.getmtime(p) < since:
339
+ continue
340
+ if name.startswith("denied-"):
341
+ ledger_lines += 1
342
+ continue
343
+ receipts += 1
344
+ try:
345
+ with open(p, encoding="utf-8", errors="replace") as f:
346
+ rec = json.load(f)
347
+ cid = rec.get("child_session_id") if isinstance(rec, dict) else None
348
+ if isinstance(cid, str) and cid:
349
+ children += 1
350
+ except (OSError, ValueError):
351
+ pass
352
+ return receipts, children, ledger_lines
353
+
354
+
355
+ def counts_lint(counts_file, dirs, since_iso=None):
356
+ if not dirs:
357
+ print("counts_lint: --counts requires at least one --dir (fail-closed)")
358
+ return 1
359
+ try:
360
+ actual = scan_counts(dirs, since_iso)
361
+ except ValueError as e:
362
+ print("counts_lint: %s" % e)
363
+ return 1
364
+ receipts, children, ledger_lines = actual
365
+ try:
366
+ with open(counts_file, encoding="utf-8", errors="replace") as f:
367
+ lines = f.read().splitlines()
368
+ except OSError as e:
369
+ print("counts_lint: counts file unreadable: %s (%s)" % (counts_file, e))
370
+ return 1
371
+ failures = 0
372
+ claims = 0
373
+ for no, line in enumerate(lines, 1):
374
+ for m in COUNT_CLAIM_RE.finditer(line):
375
+ claims += 1
376
+ n = int(m.group(1))
377
+ noun = m.group(2).lower().replace("_", " ").replace("-", " ")
378
+ if "child" in noun:
379
+ got, label = children, "children"
380
+ elif "receipt" in noun:
381
+ got, label = receipts, "receipts"
382
+ else:
383
+ got, label = ledger_lines, "ledger lines"
384
+ if n == got:
385
+ print("OK line %d: claimed %d %s == actual %d (dirs=%d)" % (no, n, label, got, len(dirs)))
386
+ else:
387
+ print("FAIL line %d: COUNT MISMATCH: claimed %d %s, mechanically derived actual %d over --dir set"
388
+ % (no, n, label, got))
389
+ failures += 1
390
+ print("counts_lint: %d lines; count-claims=%d; receipts=%d; children=%d; ledger_lines=%d; failures=%d"
391
+ % (len(lines), claims, receipts, children, ledger_lines, failures))
392
+ return 1 if failures else 0
393
+
394
+
395
+ def main():
396
+ ap = argparse.ArgumentParser(description="RM-26/RM-37 mechanical claim verifier (paths, hashes, and cited session/dispatch/receipt ids) + round-10 R9-07 count-claim verification")
397
+ mode = ap.add_mutually_exclusive_group(required=True)
398
+ mode.add_argument("--report", metavar="FILE", help="v1 surface: path+sha + evidence-context existence checks")
399
+ mode.add_argument("--claims", metavar="FILE", help="v2 surface: --report checks PLUS identifier resolution (DB mode=ro + allowlist)")
400
+ ap.add_argument("--unverifiable", metavar="FILE", help="explicit declared-unverifiable registry (purge victims: resolve + annotate, never silently)")
401
+ mode.add_argument("--counts", metavar="FILE", help="count-claim surface (R9-07): verify '<N> children|dispatched children|receipts|ledger lines' lines against a mechanical count over the --dir set")
402
+ ap.add_argument("--base", default=".", help="base dir for relative backtick paths")
403
+ ap.add_argument("--db", default=None, help="authoritative opencode DB for session/message rows (read-only)")
404
+ ap.add_argument("--dir", action="append", default=None, metavar="DIR", help="count mode: directory scanned (repeatable; e.g. dispatch receipts dir, ledger dir)")
405
+ ap.add_argument("--since", default=None, metavar="UTC", help="count mode: only count files modified at/after this UTC instant (ISO-8601)")
406
+ a = ap.parse_args()
407
+ if a.counts:
408
+ sys.exit(counts_lint(a.counts, a.dir or [], a.since))
409
+ target = a.claims if a.claims else a.report
410
+ check_ids = bool(a.claims)
411
+ db_path = a.db or default_db_path()
412
+ failures, _claims = lint(target, os.path.abspath(a.base), check_ids=check_ids, db_path=db_path, unverifiable_path=getattr(a, "unverifiable", None))
413
+ sys.exit(1 if failures else 0)
414
+
415
+
416
+ if __name__ == "__main__":
417
+ main()
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ # r20 production-topology bench arena: FULL launcher + plugin + gates running
3
+ # against a candidate tree. Method: copy the control tree minus bulk, then
4
+ # rewrite ONLY the launcher's CONTROL_ROOT literal to the arena (sed-verified
5
+ # single-line patch; every gate expression stays byte-identical, they just
6
+ # evaluate on the candidate tree — this is the honest quarantine).
7
+ # Usage: seed-r20-arena.sh <arena-root> <candidate-worktree-or-empty>
8
+ set -euo pipefail
9
+ ARENA="${1:?arena root}"; CAND="${2:-}"
10
+ SRC=/home/lab/workspace/pcb-control
11
+ rm -rf "$ARENA"; mkdir -p "$ARENA"
12
+ # bulk exclusions: reports/evidence are outputs, checkpoints/dispatches are state,
13
+ # node_modules + hermetic bin get symlinked (110MB, content-identical).
14
+ tar -C "$SRC" \
15
+ --exclude='./reports' --exclude='./evidence-links' --exclude='./state' \
16
+ --exclude='./dispatches' --exclude='./fixtures' --exclude='./node_modules' \
17
+ --exclude='*/node_modules' --exclude='./profiles/production/bin' \
18
+ --exclude='./.git' -cf - . | tar -C "$ARENA" -xf -
19
+ mkdir -p "$ARENA/state/capabilities" "$ARENA/reports/recovery" "$ARENA/fixtures" "$ARENA/dispatches"
20
+ # bin + node_modules: NO copies, NO links — r20-bench.sh stacks host ro-binds at
21
+ # their literals inside the sandbox. Remove any stale self-looping links from
22
+ # earlier seed versions so the mount points and the find-scan stay honest.
23
+ rm -f "$ARENA/profiles/production/bin"
24
+ find "$ARENA" -maxdepth 6 -type l -name node_modules -delete
25
+ find "$ARENA" -maxdepth 6 -xtype l -delete # dangling links only
26
+ # bwrap model: ARENA is bind-mounted AT $SRC inside the sandbox; every literal
27
+ # path (launcher CONTROL_ROOT, plugin profileOf, overlay plugin dirs) holds with
28
+ # zero edits. production-home is copied wholesale (config-home input; per v3
29
+ # lesson a degraded copy fails G1 — so copy everything and re-link vendored
30
+ # node_modules below); writes still land only under $SRC (bind-mounted arena)
31
+ # and /tmp.
32
+ # overlay keeps ABSOLUTE src paths: inside bwrap those literals ARE the arena (bind-mounted). No rewrite.
33
+ # candidate overlay: genome delivery = role files (+ plugin if a candidate ships one)
34
+ if [ -n "$CAND" ]; then
35
+ [ -d "$CAND/agents" ] && for t in internal-control production-console user-console; do
36
+ mkdir -p "$ARENA/$t/.opencode/agents"; cp -f "$CAND"/agents/*.md "$ARENA/$t/.opencode/agents/" 2>/dev/null || true; done
37
+ [ -f "$CAND/plugins/pcb-control-plane.ts" ] && cp -f "$CAND/plugins/pcb-control-plane.ts" "$ARENA/profiles/production/plugins/pcb-control-plane.ts"
38
+ [ -f "$CAND/launch-pins.json" ] && cp -f "$CAND/launch-pins.json" "$ARENA/profiles/production/launch-pins.json"
39
+ fi
40
+ # the single-line root rewrite is GONE (bwrap keeps literals; see header note)
41
+ # after plugin swap, re-pin plugin_sha256 + drop integrity-window notes that bind arena to src
42
+ python3 - "$ARENA" <<'PY'
43
+ import sys, os, json, hashlib
44
+ arena = sys.argv[1]
45
+ pins_path = os.path.join(arena, "profiles/production/launch-pins.json")
46
+ plg = os.path.join(arena, "profiles/production/plugins/pcb-control-plane.ts")
47
+ d = json.load(open(pins_path))
48
+ d["plugin_sha256"] = hashlib.sha256(open(plg,"rb").read()).hexdigest()
49
+ json.dump(d, open(pins_path,"w"), indent=2); open(pins_path,"a").write("\n")
50
+ PY
51
+ L="$ARENA/tools/start_production_orchestrator_ui.sh"
52
+ grep -c 'CONTROL_ROOT="/home/lab/workspace/pcb-control"' "$L" | grep -qx 1 || { echo "SEED_FAIL: CONTROL_ROOT literal changed shape"; exit 1; }
53
+ bash -n "$L"
54
+ echo "ARENA_SEEDED $ARENA plugin=$(sha256sum "$ARENA/profiles/production/plugins/pcb-control-plane.ts" | cut -c1-12)"
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # seed-sandbox.sh — PCB bench per-unit arena seeding (evolution harness, NOT board work).
3
+ # Creates a fresh isolated arena and copies the STARTER tool kit + inputs only.
4
+ # The team's own improvements must land in tools-v2/ inside the arena; the seeded
5
+ # tools/ stay pristine so the grader can diff what the team actually changed.
6
+ set -euo pipefail
7
+ ARENA="${1:?usage: seed-sandbox.sh <arena-dir> <seed-src-dir>}"
8
+ SEED="${2:?seed source dir (graders/pcb-agent/seed)}"
9
+ rm -rf "$ARENA"; mkdir -p "$ARENA/tools" "$ARENA/fab-out"
10
+ cp "$SEED"/tools/*.py "$SEED"/tools/*.sh "$ARENA/tools/"
11
+ cp "$SEED"/spec.json "$SEED"/esp32s3-r12-BRIEF.txt "$ARENA/"
12
+ chmod +w "$ARENA/tools/"* 2>/dev/null || true
13
+ echo "SEEDED arena=$ARENA tools=$(ls "$ARENA/tools" | wc -l) inputs=$(ls "$ARENA"/*.json "$ARENA"/*.txt | wc -l)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tachikomagundam/abathur",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Evolution harness: observe failures, mutate, re-bench, select — human-gated promotion, offline lineage bundles.",
5
5
  "author": "TachikomaGundam",
6
6
  "type": "module",
@@ -9,7 +9,7 @@ top-level command (`genome`, `run`, `status`, `bundle`, `graft`, `self-eval`,
9
9
  with `command` set to the first word and `extra` set to the remaining tokens,
10
10
  then report the CLI exit code (0 ok / 1 blocked decision / 2 cannot-answer)
11
11
  and the relevant lines of its output. If no request was given, call the tool
12
- with `command: "--help"` and summarize the command list. `promote` and
13
- `tombstone` cannot be called through the tool at all — the tool refuses them.
12
+ with `command: "--help"` and summarize the command list. `promote`,
13
+ `tombstone` and `retract` cannot be called through the tool at all — the tool refuses them.
14
14
  They are human gates that belong to a terminal: if the user asks for one,
15
- tell them to run `abathur promote …` / `abathur tombstone …` there.
15
+ tell them to run `abathur promote …` / `abathur tombstone …` / `abathur retract …` there.
package/plugin/abathur.ts CHANGED
@@ -1,4 +1,4 @@
1
- // abathur-opencode-plugin v0.2.6
1
+ // abathur-opencode-plugin v0.2.8
2
2
  // Official opencode plugin adapter for the abathur evolution harness.
3
3
  // Registers ONE agent tool, `abathur`, that shells out to the abathur CLI —
4
4
  // argv-only (node:child_process execFile, never a shell), top-level commands
@@ -29,7 +29,7 @@ const TIMEOUT_MS = 120_000;
29
29
  const MAX_OUTPUT_BYTES = 64 * 1024;
30
30
 
31
31
  /** The seven tool-reachable top-level commands plus --help. Anything else is refused locally.
32
- * promote/tombstone are human gates and deliberately terminal-only (0.2.1): not reachable here. */
32
+ * promote/tombstone/retract are human gates and deliberately terminal-only (0.2.1/0.2.8): not reachable here. */
33
33
  const ALLOWED_COMMANDS: readonly string[] = [
34
34
  "genome",
35
35
  "run",
@@ -133,10 +133,10 @@ top-level command (\`genome\`, \`run\`, \`status\`, \`bundle\`, \`graft\`, \`sel
133
133
  with \`command\` set to the first word and \`extra\` set to the remaining tokens,
134
134
  then report the CLI exit code (0 ok / 1 blocked decision / 2 cannot-answer)
135
135
  and the relevant lines of its output. If no request was given, call the tool
136
- with \`command: "--help"\` and summarize the command list. \`promote\` and
137
- \`tombstone\` cannot be called through the tool at all — the tool refuses them.
136
+ with \`command: "--help"\` and summarize the command list. \`promote\`,
137
+ \`tombstone\` and \`retract\` cannot be called through the tool at all — the tool refuses them.
138
138
  They are human gates that belong to a terminal: if the user asks for one,
139
- tell them to run \`abathur promote …\` / \`abathur tombstone …\` there.
139
+ tell them to run \`abathur promote …\` / \`abathur tombstone …\` / \`abathur retract …\` there.
140
140
  `;
141
141
 
142
142
  export default {
@@ -168,7 +168,7 @@ export default {
168
168
  "(0 ok, 1 blocked decision, 2 cannot-answer). Honest privilege note: this " +
169
169
  "tool carries bash-equivalent privilege — `run` and `genome` legitimately " +
170
170
  "spawn mutator/engine binaries by design — so the allowlist limits typos " +
171
- "and UX, not capability. `promote` and `tombstone` are deliberately NOT " +
171
+ "and UX, not capability. `promote`, `tombstone` and `retract` are deliberately NOT " +
172
172
  "reachable here: they are human gates, run in a terminal.",
173
173
  args: {
174
174
  command: tool.schema