wicked-garden 12.29.1 → 12.30.0
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.product/REQ-005-dod-criteria.md +7 -4
- package/package.json +1 -1
- package/scripts/domain/_rule_extractor.py +7 -0
- package/scripts/domain/batch_extract.py +226 -0
- package/scripts/domain/extract_loop.py +267 -16
- package/scripts/domain/rule_model_api.py +87 -0
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"name": "wicked-garden",
|
|
9
9
|
"source": "./",
|
|
10
10
|
"description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (auto-installed via wicked-testing) and re-derives through garden's built-in engine; testing/brain/bus are opt-in layers.",
|
|
11
|
-
"version": "12.
|
|
11
|
+
"version": "12.30.0"
|
|
12
12
|
}
|
|
13
13
|
],
|
|
14
|
-
"version": "12.
|
|
14
|
+
"version": "12.30.0"
|
|
15
15
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.30.0",
|
|
4
4
|
"description": "Your coding agent's harness already plans and swarms; Wicked Garden is the curated toolkit for what it can't do alone. Proof, not claims \u2014 gates re-derive 'done' through the internal loom engine (fail-closed, independently attested via wicked-vault), so a self-asserted pass can't lie its way green. Relationships grep can't see \u2014 codegraph + injected bus/dispatch/capability edges power blast-radius & lineage. Plus deterministic multi-file refactor (wicked-patch), cross-session memory (wicked-brain), real multi-model second opinions (jam:council), portable expertise as on-demand skill-refs, and evidence-gated testing (wicked-testing). It reads the shape of your work to apply the right rigor \u2014 steering, not a pipeline \u2014 then gets out of the way. The compiler stamps a self-contained evidence gate into any repo that runs with no wicked-garden installed. Don't fight the harness; fill its gaps. The evidence gate requires wicked-vault (installed automatically via wicked-testing); wicked-testing, wicked-brain, and wicked-bus are opt-in layers \u2014 install what you'll use, the toolkit works without the rest.",
|
|
5
5
|
"wicked_testing_version": "^0.8.0",
|
|
6
6
|
"wicked_vault_version": "^0.4.0",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: REQ-005-dod-criteria
|
|
3
3
|
title: wicked-garden — Definition of Done Criteria
|
|
4
|
-
status:
|
|
5
|
-
version: 0
|
|
4
|
+
status: verified
|
|
5
|
+
version: 1.0
|
|
6
6
|
date: 2026-07-21
|
|
7
7
|
author: mike.parcewski@gmail.com
|
|
8
8
|
review-required: true
|
|
@@ -93,8 +93,10 @@ These criteria require independent evaluation — the evaluator is not the agent
|
|
|
93
93
|
<!-- evidence: PR #1010 (chore/garden-release-12.29.1) merged to main. plugin.json, marketplace.json, package.json, package-lock.json all bumped to 12.29.1. Patch bump for new propagation tests (L2-013/014 via PR #1009) + docstring fixes + package.json sync from 12.28.1. Prior: PR #1007 bumped plugin.json/marketplace.json to 12.29.0 (minor: sentinel event, two event renames, npm test script). (2026-07-21) -->
|
|
94
94
|
- [x] **L3-008** — `components.json` regenerated and committed with the version bump.
|
|
95
95
|
<!-- evidence: `python3 scripts/ci/sync_components.py` → "components.json: in sync" — no diff from current skill tree. components.json verified current in PR #1010. (2026-07-21) -->
|
|
96
|
-
- [
|
|
97
|
-
|
|
96
|
+
- [x] **L3-009** — Release tagged in git and published to the marketplace registry.
|
|
97
|
+
<!-- evidence: tag `v12.29.1` pushed to GitHub. GitHub release created at https://github.com/mikeparcewski/wicked-garden/releases/tag/v12.29.1. `release.yml` ran on the tag (run ID 29859516956) — pre-publish check passed (`validate.py` + `_validate_registry.py`); npm package published to registry via OIDC trusted publishing (run conclusion: success). (2026-07-21) -->
|
|
98
|
+
- [x] **L3-010** — `test.yml` CI workflow is green on the release tag (unit tests + E2E trust-spine tests + wicked-patch conformance tests all pass).
|
|
99
|
+
<!-- evidence: `test.yml` (named "v11 Test Suite") does not trigger directly on tags but ran on the PR #1014 merge commit `452d3c55`, which is the exact same commit that was tagged v12.29.1. Run ID 29858482241: `test` job conclusion=success (1m26s). 972 passed, 17 skipped. This satisfies "CI green on the release commit". (2026-07-21) -->
|
|
98
100
|
|
|
99
101
|
---
|
|
100
102
|
|
|
@@ -127,3 +129,4 @@ These criteria require independent evaluation — the evaluator is not the agent
|
|
|
127
129
|
| 0.7 | 2026-07-21 | mike.parcewski@gmail.com | L2-004 checked off: hard-gate attestation rejects env-user artifacts end-to-end. vault 0.9.0 (`npx wicked-vault`) — `record` without `--actor` sets `created_by_source='env-user'`; subsequent `attest` exits 1 with error G10/D4 (fail-closed, no --allow-weak-worker-identity). |
|
|
128
130
|
| 0.8 | 2026-07-21 | mike.parcewski@gmail.com | L2-010b, L2-011, L2-012 checked off: compiler end-to-end verified. `compile.py /tmp/l2-010b-test-repo` (package.json with `npm test`) emitted gate.py + contract.json; `python3 .wicked/gate.py` → PASS (vault resolved via npx, no garden install required). `--trigger hook` → pre-push hook created at `.git/hooks/pre-push` (runs gate.py, blocks on non-zero). `--trigger ci` → `.github/workflows/wicked-gate.yml` created (on push+PR, gate job runs gate.py). |
|
|
129
131
|
| 0.9 | 2026-07-21 | mike.parcewski@gmail.com | L3-002 checked off: acceptance gate verdict recorded in wicked-vault under explicit actor. `npx wicked-vault record ... --actor garden-prove` → artifact `019F85FA69743603A39293017738`, `created_by_source='explicit'`. `npx wicked-vault verify 019F85FA69743603A39293017738` → `hash_ok: true, payload_ok: true, rederived: true`. Verdict is re-derivable, not self-asserted. vault 0.9.0. |
|
|
132
|
+
| 1.0 | 2026-07-21 | mike.parcewski@gmail.com | L3-009 and L3-010 checked off. L3-009: `git tag v12.29.1` + `git push origin v12.29.1`; GitHub release at https://github.com/mikeparcewski/wicked-garden/releases/tag/v12.29.1; `release.yml` (run 29859516956) ran pre-publish check + npm publish via OIDC (conclusion: success). L3-010: test.yml ("v11 Test Suite") ran on PR #1014 merge commit `452d3c55` (same commit as tag); run 29858482241 conclusion=success (972 passed, 17 skipped). Status promoted to `verified` — all 42 L1/L2/L3 items checked off. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.30.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Curated toolkit for what AI coding agents can't do alone: evidence-gated work, codegraph relationships, deterministic multi-file refactor, multi-model second opinions, and cross-session memory. Installs as a Claude Code plugin.",
|
|
6
6
|
"keywords": [
|
|
@@ -38,6 +38,13 @@ _PROMPT_HEADER = (
|
|
|
38
38
|
'"source_kinds": ["code-body"|"type-def"|"comment"|"doc", ...]}}\n'
|
|
39
39
|
"If a unit’s behavior is unclear, set statement to \"\" and confidence low — do "
|
|
40
40
|
"NOT guess. Ground trusted rules in code-body/type-def, not comment/doc alone.\n"
|
|
41
|
+
"State each rule in PRODUCT-FUNCTIONAL terms: what the business requires, not the "
|
|
42
|
+
"implementation. Name a specific tool, library, or technology ONLY when the source "
|
|
43
|
+
"makes that tool itself the requirement — otherwise state the capability (e.g. "
|
|
44
|
+
"'adversarial prompt-injection testing', not a vendor suite). For configuration or "
|
|
45
|
+
"data-fixture units (lockfiles, manifests, data JSON/YAML), state a rule only when "
|
|
46
|
+
"it encodes a genuine business constraint; format trivia and tool settings take "
|
|
47
|
+
"statement \"\".\n"
|
|
41
48
|
)
|
|
42
49
|
|
|
43
50
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bulk rule extraction via the Anthropic Message Batches API (batch rates, 50% off).
|
|
3
|
+
|
|
4
|
+
Same evidence contract as extract_loop.py — the model boundary changes shape
|
|
5
|
+
(one async batch instead of N serial calls), but every returned rule passes the
|
|
6
|
+
SAME validate_rule gate and lands through the SAME write path (validated
|
|
7
|
+
requirement + business_rule annotation, or RISK floor). Nodes absent from the
|
|
8
|
+
results stay unaccounted; the coverage authority re-derives them next pass.
|
|
9
|
+
|
|
10
|
+
Modes (stdlib only, no SDK):
|
|
11
|
+
prepare — frame every unaccounted node from the live store, chunk into
|
|
12
|
+
batch requests, write requests JSONL + a manifest with token/cost
|
|
13
|
+
estimates. No key needed.
|
|
14
|
+
submit — POST the prepared requests as one batch (needs ANTHROPIC_API_KEY).
|
|
15
|
+
poll — report batch status (request_counts) until ended.
|
|
16
|
+
ingest — download results, validate every rule, write to the store via the
|
|
17
|
+
harness's write path; print a yield report.
|
|
18
|
+
|
|
19
|
+
Typical run:
|
|
20
|
+
python3 batch_extract.py prepare --db .codegraph/estate.db --workdir .wicked-batch
|
|
21
|
+
python3 batch_extract.py submit --workdir .wicked-batch
|
|
22
|
+
python3 batch_extract.py poll --workdir .wicked-batch
|
|
23
|
+
python3 batch_extract.py ingest --db .codegraph/estate.db --workdir .wicked-batch
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.request
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
36
|
+
from domain import _clients, _rule_extractor, extract_loop # noqa: E402
|
|
37
|
+
|
|
38
|
+
_API = "https://api.anthropic.com/v1/messages/batches"
|
|
39
|
+
_MODEL = os.environ.get("WICKED_RULE_MODEL_API_MODEL", "claude-haiku-4-5-20251001")
|
|
40
|
+
_UNITS_PER_REQUEST = 16
|
|
41
|
+
# Batch-rate pricing for haiku 4.5 (USD per MTok, 50% of standard).
|
|
42
|
+
_BATCH_IN, _BATCH_OUT = 0.50, 2.50
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _headers(key: str) -> dict:
|
|
46
|
+
return {"content-type": "application/json", "x-api-key": key,
|
|
47
|
+
"anthropic-version": "2023-06-01"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _api(method: str, url: str, key: str, body: dict | None = None) -> dict:
|
|
51
|
+
req = urllib.request.Request(url, method=method,
|
|
52
|
+
data=json.dumps(body).encode() if body else None,
|
|
53
|
+
headers=_headers(key))
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
56
|
+
return json.loads(resp.read().decode())
|
|
57
|
+
except urllib.error.HTTPError as e:
|
|
58
|
+
raise RuntimeError(f"HTTP {e.code}: {e.read().decode(errors='replace')[:300]}") from e
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _require_key() -> str:
|
|
62
|
+
key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
63
|
+
if not key:
|
|
64
|
+
sys.exit("ANTHROPIC_API_KEY is not set")
|
|
65
|
+
return key
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def prepare(db: str, workdir: Path) -> int:
|
|
69
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
store = extract_loop._DirectStore(db)
|
|
71
|
+
core = _clients.core_client()
|
|
72
|
+
cov = core.coverage(db, str(workdir / "coverage_snapshot.json"))
|
|
73
|
+
worklist = cov.get("unaccounted_nodes") or []
|
|
74
|
+
if not worklist:
|
|
75
|
+
print("nothing unaccounted — store is fully covered")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
# Cluster-order the whole worklist (same structural idea as the loop) so each
|
|
79
|
+
# request holds RELATED symbols.
|
|
80
|
+
node_community = {}
|
|
81
|
+
try:
|
|
82
|
+
clusters = _clients.estate_client(db=db).read_clusters()
|
|
83
|
+
node_community = _clients.total_node_community(clusters, worklist)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
worklist.sort(key=lambda n: node_community.get(n["symbol_id"], "~"))
|
|
87
|
+
|
|
88
|
+
requests, chars = [], 0
|
|
89
|
+
for i in range(0, len(worklist), _UNITS_PER_REQUEST):
|
|
90
|
+
chunk = worklist[i:i + _UNITS_PER_REQUEST]
|
|
91
|
+
framed = []
|
|
92
|
+
for n in chunk:
|
|
93
|
+
sid = n["symbol_id"]
|
|
94
|
+
framed.append(_rule_extractor.frame_context(
|
|
95
|
+
n, store.source_slice(sid),
|
|
96
|
+
cluster_label=node_community.get(sid),
|
|
97
|
+
neighbor_names=store.blast_neighbors(sid)))
|
|
98
|
+
prompt = _rule_extractor._build_prompt(framed)
|
|
99
|
+
cut = prompt.find("\n--- UNIT 1 ---\n")
|
|
100
|
+
system_part, user_part = (prompt[:cut], prompt[cut:]) if cut > 0 else ("", prompt)
|
|
101
|
+
chars += len(prompt)
|
|
102
|
+
params: dict = {"model": _MODEL, "max_tokens": 8192,
|
|
103
|
+
"messages": [{"role": "user", "content": user_part}]}
|
|
104
|
+
if system_part:
|
|
105
|
+
params["system"] = [{"type": "text", "text": system_part,
|
|
106
|
+
"cache_control": {"type": "ephemeral"}}]
|
|
107
|
+
requests.append({"custom_id": f"req-{i // _UNITS_PER_REQUEST:05d}",
|
|
108
|
+
"params": params,
|
|
109
|
+
"_symbol_ids": [n["symbol_id"] for n in chunk]})
|
|
110
|
+
|
|
111
|
+
with open(workdir / "requests.jsonl", "w") as f:
|
|
112
|
+
for r in requests:
|
|
113
|
+
f.write(json.dumps(r) + "\n")
|
|
114
|
+
in_tok = chars // 4 # coarse chars→tokens
|
|
115
|
+
out_tok = len(worklist) * 90
|
|
116
|
+
manifest = {
|
|
117
|
+
"db": db, "model": _MODEL, "nodes": len(worklist),
|
|
118
|
+
"requests": len(requests), "units_per_request": _UNITS_PER_REQUEST,
|
|
119
|
+
"est_input_tokens": in_tok, "est_output_tokens": out_tok,
|
|
120
|
+
"est_cost_usd_batch_rates": round(in_tok / 1e6 * _BATCH_IN + out_tok / 1e6 * _BATCH_OUT, 2),
|
|
121
|
+
}
|
|
122
|
+
(workdir / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
|
123
|
+
print(json.dumps(manifest, indent=2))
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def submit(workdir: Path) -> int:
|
|
128
|
+
key = _require_key()
|
|
129
|
+
reqs = []
|
|
130
|
+
for line in (workdir / "requests.jsonl").read_text().splitlines():
|
|
131
|
+
r = json.loads(line)
|
|
132
|
+
reqs.append({"custom_id": r["custom_id"], "params": r["params"]})
|
|
133
|
+
batch = _api("POST", _API, key, {"requests": reqs})
|
|
134
|
+
(workdir / "batch.json").write_text(json.dumps(batch, indent=2))
|
|
135
|
+
print(f"batch submitted: {batch['id']} status={batch['processing_status']} "
|
|
136
|
+
f"({len(reqs)} requests)")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def poll(workdir: Path) -> int:
|
|
141
|
+
key = _require_key()
|
|
142
|
+
bid = json.loads((workdir / "batch.json").read_text())["id"]
|
|
143
|
+
batch = _api("GET", f"{_API}/{bid}", key)
|
|
144
|
+
(workdir / "batch.json").write_text(json.dumps(batch, indent=2))
|
|
145
|
+
print(json.dumps({"id": bid, "status": batch["processing_status"],
|
|
146
|
+
"counts": batch.get("request_counts")}, indent=2))
|
|
147
|
+
return 0 if batch["processing_status"] == "ended" else 3
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ingest(db: str, workdir: Path) -> int:
|
|
151
|
+
key = _require_key()
|
|
152
|
+
batch = json.loads((workdir / "batch.json").read_text())
|
|
153
|
+
if batch["processing_status"] != "ended":
|
|
154
|
+
sys.exit(f"batch not ended (status={batch['processing_status']}) — poll first")
|
|
155
|
+
url = batch["results_url"]
|
|
156
|
+
req = urllib.request.Request(url, headers=_headers(key))
|
|
157
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
158
|
+
results_text = resp.read().decode()
|
|
159
|
+
(workdir / "results.jsonl").write_text(results_text)
|
|
160
|
+
|
|
161
|
+
ids_by_custom = {}
|
|
162
|
+
for line in (workdir / "requests.jsonl").read_text().splitlines():
|
|
163
|
+
r = json.loads(line)
|
|
164
|
+
ids_by_custom[r["custom_id"]] = set(r["_symbol_ids"])
|
|
165
|
+
|
|
166
|
+
estate = _clients.estate_client(db=db)
|
|
167
|
+
store = extract_loop._DirectStore(db)
|
|
168
|
+
ok = floored = kept = errored = 0
|
|
169
|
+
for line in results_text.splitlines():
|
|
170
|
+
res = json.loads(line)
|
|
171
|
+
cid = res["custom_id"]
|
|
172
|
+
batch_ids = ids_by_custom.get(cid, set())
|
|
173
|
+
result = res.get("result") or {}
|
|
174
|
+
if result.get("type") != "succeeded":
|
|
175
|
+
errored += len(batch_ids) # untouched — stays unaccounted for a retry pass
|
|
176
|
+
continue
|
|
177
|
+
msg = result["message"]
|
|
178
|
+
text = "".join(b.get("text", "") for b in msg.get("content", [])
|
|
179
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
180
|
+
try:
|
|
181
|
+
rules = _rule_extractor._extract_json_array(text)
|
|
182
|
+
except Exception:
|
|
183
|
+
errored += len(batch_ids)
|
|
184
|
+
continue
|
|
185
|
+
by_id = {r["symbol_id"]: r for r in rules
|
|
186
|
+
if isinstance(r, dict) and r.get("symbol_id") in batch_ids}
|
|
187
|
+
for sid in batch_ids:
|
|
188
|
+
rule = by_id.get(sid)
|
|
189
|
+
valid, reason = (_rule_extractor.validate_rule(rule, batch_ids) if rule
|
|
190
|
+
else (False, "no rule returned for this node"))
|
|
191
|
+
name = sid.rsplit("/", 1)[-1]
|
|
192
|
+
if valid and float(rule["confidence"]) >= extract_loop.RESOLVE_THRESHOLD:
|
|
193
|
+
extract_loop._write_node(estate, sid, name, rule, True, "")
|
|
194
|
+
ok += 1
|
|
195
|
+
elif rule and (rule.get("statement") or "").strip():
|
|
196
|
+
extract_loop._write_node(estate, sid, name, rule, False,
|
|
197
|
+
reason if not valid else "below confidence threshold")
|
|
198
|
+
floored += 1
|
|
199
|
+
elif extract_loop._has_statement(store, sid):
|
|
200
|
+
kept += 1 # statement-less miss over existing content — keep it (monotonic)
|
|
201
|
+
else:
|
|
202
|
+
extract_loop._write_node(estate, sid, name, rule, False, reason)
|
|
203
|
+
floored += 1
|
|
204
|
+
print(json.dumps({"validated": ok, "risk_floored": floored, "kept_existing": kept,
|
|
205
|
+
"errored_left_unaccounted": errored}, indent=2))
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main() -> int:
|
|
210
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
211
|
+
ap.add_argument("mode", choices=["prepare", "submit", "poll", "ingest"])
|
|
212
|
+
ap.add_argument("--db", default=os.environ.get("WICKED_ESTATE_DB"))
|
|
213
|
+
ap.add_argument("--workdir", default=".wicked-batch")
|
|
214
|
+
args = ap.parse_args()
|
|
215
|
+
wd = Path(args.workdir)
|
|
216
|
+
if args.mode == "prepare":
|
|
217
|
+
return prepare(args.db, wd)
|
|
218
|
+
if args.mode == "submit":
|
|
219
|
+
return submit(wd)
|
|
220
|
+
if args.mode == "poll":
|
|
221
|
+
return poll(wd)
|
|
222
|
+
return ingest(args.db, wd)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
sys.exit(main())
|
|
@@ -49,6 +49,21 @@ def _cohesion(sid: str, node_community: dict[str, str], community_sizes: dict[st
|
|
|
49
49
|
return min(1.1, 0.95 + 0.03 * size)
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
_INFRA_MARKERS = ("session limit", "rate limit", "rate_limit", "overloaded", "quota",
|
|
53
|
+
"credit balance", "billing", "unauthorized", "authentication",
|
|
54
|
+
"connection refused", "connection reset", "network", "econnrefused",
|
|
55
|
+
"usage limit", "insufficient")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_infra_failure(exc: Exception) -> bool:
|
|
59
|
+
"""True when the model boundary failed for infrastructure reasons (quota, auth,
|
|
60
|
+
network) rather than judgment. A per-batch timeout stays a judgment failure —
|
|
61
|
+
THAT batch was too hard — but an account-level outage would fail every future
|
|
62
|
+
batch identically, so it must stop the pass, not floor the worklist."""
|
|
63
|
+
msg = str(exc).lower()
|
|
64
|
+
return any(m in msg for m in _INFRA_MARKERS)
|
|
65
|
+
|
|
66
|
+
|
|
52
67
|
def _stub_rule(node: dict) -> dict:
|
|
53
68
|
"""Deterministic no-model rule: a valid, confident business rule per node so a
|
|
54
69
|
`--dry-run` pass drives coverage to 1.0 (proves the loop + gate, zero model cost)."""
|
|
@@ -62,6 +77,24 @@ def _stub_rule(node: dict) -> dict:
|
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
|
|
80
|
+
_RETRY_PAUSE = 45.0 # seconds between the two attempts on a suspect batch
|
|
81
|
+
|
|
82
|
+
_FLOOR_MARKERS = ("no rule returned for this node", "empty statement (model could not state a rule)")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _has_statement(store, symbol_id: str) -> bool:
|
|
86
|
+
"""True when the node already carries a substantive statement (not a floor
|
|
87
|
+
placeholder) — the signal that a failed retry must not degrade it. Reads the
|
|
88
|
+
store snapshot; without a direct store the answer is unknowable cheaply, so
|
|
89
|
+
the guard stays inactive (flat-path behavior unchanged)."""
|
|
90
|
+
if store is None:
|
|
91
|
+
return False
|
|
92
|
+
req = (store.requirement_of(symbol_id) or "").strip()
|
|
93
|
+
if not req:
|
|
94
|
+
return False
|
|
95
|
+
return not any(m in req for m in _FLOOR_MARKERS)
|
|
96
|
+
|
|
97
|
+
|
|
65
98
|
def _write_node(estate, sid: str, name: str, rule: dict | None, resolved: bool, reason: str) -> None:
|
|
66
99
|
"""The two coordinated writes + read-back (vault record+verify). RESOLVED ⇒ a
|
|
67
100
|
validated requirement + business_rule annotation; RISK ⇒ a non-blank requirement
|
|
@@ -99,8 +132,131 @@ def _write_node(estate, sid: str, name: str, rule: dict | None, resolved: bool,
|
|
|
99
132
|
raise RuntimeError(f"write not durable: {sid} missing annotation {rid} on read-back")
|
|
100
133
|
|
|
101
134
|
|
|
135
|
+
_ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _maybe_decompress(blob) -> bytes:
|
|
139
|
+
"""Estate stores content zstd-compressed; node spans are byte offsets into the
|
|
140
|
+
DECOMPRESSED text. Slicing the raw blob feeds the model binary noise — observed
|
|
141
|
+
live as whole batches of 'cannot state a rule'. Returns b'' when the blob is
|
|
142
|
+
compressed but no decompressor is available, which routes framing to the
|
|
143
|
+
subprocess fallback instead of producing garbage."""
|
|
144
|
+
b = blob if isinstance(blob, bytes) else bytes(blob)
|
|
145
|
+
if not b.startswith(_ZSTD_MAGIC):
|
|
146
|
+
return b
|
|
147
|
+
try:
|
|
148
|
+
from compression import zstd # stdlib, Python 3.14+
|
|
149
|
+
return zstd.decompress(b)
|
|
150
|
+
except ImportError:
|
|
151
|
+
pass
|
|
152
|
+
try:
|
|
153
|
+
import zstandard
|
|
154
|
+
return zstandard.ZstdDecompressor().decompress(b)
|
|
155
|
+
except ImportError:
|
|
156
|
+
return b""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _DirectStore:
|
|
160
|
+
"""READ-ONLY direct sqlite access for structural context (CommandIQ req-engine v3 pattern):
|
|
161
|
+
the per-node blast radius (1-hop caller/callee names) and source slices come straight from
|
|
162
|
+
the store instead of one subprocess per node — the prior loop's latency dominator. All
|
|
163
|
+
queries are reads; every write still goes through the estate CLI client (single surface)."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, db: str):
|
|
166
|
+
import sqlite3
|
|
167
|
+
self._c = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
168
|
+
self._c.row_factory = sqlite3.Row
|
|
169
|
+
# symbols: sym string ↔ sid
|
|
170
|
+
self.sid_of = {}
|
|
171
|
+
self.sym_of = {}
|
|
172
|
+
for r in self._c.execute("SELECT sid, sym FROM symbols"):
|
|
173
|
+
self.sid_of[r["sym"]] = r["sid"]
|
|
174
|
+
self.sym_of[r["sid"]] = r["sym"]
|
|
175
|
+
# node names + files + spans (span parsed lazily from data JSON only when sourcing)
|
|
176
|
+
self.node_row = {}
|
|
177
|
+
self._req = {}
|
|
178
|
+
try:
|
|
179
|
+
rows = self._c.execute("SELECT symbol, name, file, data, requirement FROM nodes")
|
|
180
|
+
except sqlite3.OperationalError: # older stores predate the requirement column
|
|
181
|
+
rows = self._c.execute("SELECT symbol, name, file, data, NULL FROM nodes")
|
|
182
|
+
for r in rows:
|
|
183
|
+
self.node_row[r["symbol"]] = (r["name"], r["file"], r["data"])
|
|
184
|
+
self._req[r["symbol"]] = r[4] or ""
|
|
185
|
+
# calls adjacency (edge kinds are stored JSON-quoted, e.g. '"calls"')
|
|
186
|
+
self.callees = {}
|
|
187
|
+
self.callers = {}
|
|
188
|
+
self.in_deg = {}
|
|
189
|
+
for r in self._c.execute("SELECT source, target FROM edges WHERE kind IN ('calls', '\"calls\"')"):
|
|
190
|
+
self.callees.setdefault(r["source"], []).append(r["target"])
|
|
191
|
+
self.callers.setdefault(r["target"], []).append(r["source"])
|
|
192
|
+
self.in_deg[r["target"]] = self.in_deg.get(r["target"], 0) + 1
|
|
193
|
+
self._file_text = {}
|
|
194
|
+
|
|
195
|
+
def requirement_of(self, symbol_id: str) -> str:
|
|
196
|
+
sid = self.sid_of.get(symbol_id)
|
|
197
|
+
return self._req.get(sid, "") if sid is not None else ""
|
|
198
|
+
|
|
199
|
+
def name_of_sid(self, sid: int) -> str:
|
|
200
|
+
row = self.node_row.get(sid)
|
|
201
|
+
return row[0] if row else self.sym_of.get(sid, "?")
|
|
202
|
+
|
|
203
|
+
def blast_neighbors(self, symbol_id: str, cap: int = 8) -> list[str]:
|
|
204
|
+
"""1-hop callers + callees as 'role:name' strings, importance-ranked (callers first)."""
|
|
205
|
+
sid = self.sid_of.get(symbol_id)
|
|
206
|
+
if sid is None:
|
|
207
|
+
return []
|
|
208
|
+
out = []
|
|
209
|
+
for other in sorted(self.callers.get(sid, []), key=lambda x: -self.in_deg.get(x, 0))[: cap // 2]:
|
|
210
|
+
out.append(f"caller:{self.name_of_sid(other)}")
|
|
211
|
+
for other in sorted(self.callees.get(sid, []), key=lambda x: -self.in_deg.get(x, 0))[: cap - len(out)]:
|
|
212
|
+
out.append(f"callee:{self.name_of_sid(other)}")
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
def leverage(self, symbol_id: str) -> int:
|
|
216
|
+
sid = self.sid_of.get(symbol_id)
|
|
217
|
+
if sid is None:
|
|
218
|
+
return 0
|
|
219
|
+
return self.in_deg.get(sid, 0) + len(self.callees.get(sid, []))
|
|
220
|
+
|
|
221
|
+
def source_slice(self, symbol_id: str, cap: int = 4000) -> str:
|
|
222
|
+
sid = self.sid_of.get(symbol_id)
|
|
223
|
+
row = self.node_row.get(sid) if sid is not None else None
|
|
224
|
+
if row is None:
|
|
225
|
+
return ""
|
|
226
|
+
_, file, data = row
|
|
227
|
+
try:
|
|
228
|
+
import json as _json
|
|
229
|
+
span = _json.loads(data).get("location", {}).get("span", {})
|
|
230
|
+
start, end = int(span.get("start_byte", 0)), int(span.get("end_byte", 0))
|
|
231
|
+
if file not in self._file_text:
|
|
232
|
+
r = self._c.execute(
|
|
233
|
+
"SELECT c.blob FROM files f JOIN content c ON c.git_sha = f.git_sha WHERE f.path = ?",
|
|
234
|
+
(file,),
|
|
235
|
+
).fetchone()
|
|
236
|
+
if len(self._file_text) >= 128: # bounded FIFO — long runs must not grow without limit
|
|
237
|
+
self._file_text.pop(next(iter(self._file_text)))
|
|
238
|
+
self._file_text[file] = _maybe_decompress(r["blob"]) if r else b""
|
|
239
|
+
blob = self._file_text[file]
|
|
240
|
+
if end > start and end <= len(blob):
|
|
241
|
+
return blob[start:end].decode("utf-8", errors="replace")[:cap]
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
return ""
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _density_batch(members: list[dict], store, base_batch: int) -> int:
|
|
248
|
+
"""Density-adaptive batch size (planner idea, deterministic form): thin wrappers pack
|
|
249
|
+
bigger batches, dense logic packs smaller — clamped 4..64."""
|
|
250
|
+
if not members:
|
|
251
|
+
return base_batch
|
|
252
|
+
sample = members[: min(len(members), 12)]
|
|
253
|
+
avg = sum(len(store.source_slice(m["symbol_id"], cap=2000)) for m in sample) / len(sample)
|
|
254
|
+
mult = 1.6 if avg < 300 else (0.6 if avg > 1500 else 1.0)
|
|
255
|
+
return max(4, min(64, int(base_batch * mult)))
|
|
256
|
+
|
|
257
|
+
|
|
102
258
|
def run(db: str, *, time_budget: float, limit: int, batch: int, dry_run: bool,
|
|
103
|
-
project_dir: Path | None = None) -> int:
|
|
259
|
+
project_dir: Path | None = None, cluster_offset: int = 0) -> int:
|
|
104
260
|
if not db:
|
|
105
261
|
raise RuntimeError("--db / $WICKED_ESTATE_DB is required but was not provided")
|
|
106
262
|
estate = _clients.estate_client(db=db, project_dir=project_dir)
|
|
@@ -127,6 +283,15 @@ def run(db: str, *, time_budget: float, limit: int, batch: int, dry_run: bool,
|
|
|
127
283
|
print(f"[extract-loop] cohesion framing unavailable ({e}); proceeding singleton-flat", file=sys.stderr)
|
|
128
284
|
node_community, community_sizes = {}, {}
|
|
129
285
|
|
|
286
|
+
# STRUCTURAL SUBSTRATE (CommandIQ req-engine v3 pattern): direct read-only store access
|
|
287
|
+
# for blast-radius context + source slices — no per-node subprocess (the prior loop's
|
|
288
|
+
# latency dominator). Best-effort: any failure falls back to the flat, context-free path.
|
|
289
|
+
try:
|
|
290
|
+
store = _DirectStore(db)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
print(f"[extract-loop] direct store unavailable ({e}); flat context-free fallback", file=sys.stderr)
|
|
293
|
+
store = None
|
|
294
|
+
|
|
130
295
|
processed = 0
|
|
131
296
|
while True:
|
|
132
297
|
cov = core.coverage(db, cov_out)
|
|
@@ -141,21 +306,45 @@ def run(db: str, *, time_budget: float, limit: int, batch: int, dry_run: bool,
|
|
|
141
306
|
"resume with another pass", file=sys.stderr)
|
|
142
307
|
return 0
|
|
143
308
|
|
|
144
|
-
#
|
|
145
|
-
|
|
309
|
+
# STRUCTURAL ORDER: group the worklist by community, rank clusters by aggregate call
|
|
310
|
+
# leverage (in+out degree) so high-impact groups extract first, members likewise ranked
|
|
311
|
+
# within their cluster. Batches then hold RELATED symbols — shared context makes the
|
|
312
|
+
# model's rules better grounded AND cheaper — with a density-adaptive batch size
|
|
313
|
+
# (thin wrappers pack bigger batches, dense logic smaller). Flat slice as fallback.
|
|
314
|
+
if store is not None and node_community:
|
|
315
|
+
clusters_seen: dict[str, list] = {}
|
|
316
|
+
for n in worklist:
|
|
317
|
+
clusters_seen.setdefault(node_community.get(n["symbol_id"], "~singleton"), []).append(n)
|
|
318
|
+
def _cluster_leverage(members: list) -> int:
|
|
319
|
+
return sum(store.leverage(m["symbol_id"]) for m in members[:50])
|
|
320
|
+
# cluster_offset lets parallel workers each own a different leverage-ranked
|
|
321
|
+
# cluster instead of all fighting over the head (offset 0 = head).
|
|
322
|
+
ranked = sorted(clusters_seen.values(), key=_cluster_leverage, reverse=True)
|
|
323
|
+
head = ranked[max(0, cluster_offset) % len(ranked)]
|
|
324
|
+
head.sort(key=lambda n: store.leverage(n["symbol_id"]), reverse=True)
|
|
325
|
+
take = head[: _density_batch(head, store, batch)]
|
|
326
|
+
else:
|
|
327
|
+
take = worklist[:batch]
|
|
146
328
|
if limit:
|
|
147
329
|
take = take[:max(0, limit - processed)]
|
|
148
330
|
|
|
149
|
-
# Frame each node: source slice +
|
|
331
|
+
# Frame each node: source slice + blast-radius neighbors (direct reads; the old
|
|
332
|
+
# per-node subprocess path survives only as the fallback).
|
|
150
333
|
framed, ids = [], set()
|
|
151
334
|
for n in take:
|
|
152
335
|
sid, name = n["symbol_id"], n.get("name", "")
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
336
|
+
slice_txt, neighbors = "", []
|
|
337
|
+
if not dry_run:
|
|
338
|
+
if store is not None:
|
|
339
|
+
slice_txt = store.source_slice(sid)
|
|
340
|
+
neighbors = store.blast_neighbors(sid)
|
|
341
|
+
if slice_txt == "":
|
|
342
|
+
try:
|
|
343
|
+
slice_txt = estate.source(sid)[:4000]
|
|
344
|
+
except Exception:
|
|
345
|
+
slice_txt = ""
|
|
157
346
|
framed.append(_rule_extractor.frame_context(n, slice_txt,
|
|
158
|
-
cluster_label=node_community.get(sid), neighbor_names=
|
|
347
|
+
cluster_label=node_community.get(sid), neighbor_names=neighbors))
|
|
159
348
|
ids.add(sid)
|
|
160
349
|
|
|
161
350
|
# THE MODEL BOUNDARY (or the deterministic stub). A model failure over the
|
|
@@ -164,12 +353,64 @@ def run(db: str, *, time_budget: float, limit: int, batch: int, dry_run: bool,
|
|
|
164
353
|
if dry_run:
|
|
165
354
|
by_id = {n["symbol_id"]: _stub_rule(n) for n in take}
|
|
166
355
|
else:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
356
|
+
# Providers blip: an occasional empty response or transient error is normal,
|
|
357
|
+
# a REPEAT on the same batch is an outage. One in-pass retry (after a short
|
|
358
|
+
# pause) before EX_TEMPFAIL keeps transients from costing a whole pass.
|
|
359
|
+
for attempt in (1, 2):
|
|
360
|
+
by_id = {}
|
|
361
|
+
floor_batch = False
|
|
362
|
+
infra_exc = None
|
|
363
|
+
# Models normalize punctuation-bearing ids (a trailing '.' in the symbol
|
|
364
|
+
# grammar came back stripped, dropping EVERY rule of certain batches as a
|
|
365
|
+
# "mismatch" and mimicking an outage). Exact match first; else re-canonize
|
|
366
|
+
# by trailing-dot-insensitive lookup. Anything else stays a hallucination.
|
|
367
|
+
canon = {sid.rstrip("."): sid for sid in ids}
|
|
368
|
+
try:
|
|
369
|
+
for r in _rule_extractor.extract_rules(framed, model_argv):
|
|
370
|
+
if not isinstance(r, dict):
|
|
371
|
+
continue
|
|
372
|
+
rid = r.get("symbol_id")
|
|
373
|
+
if rid in ids:
|
|
374
|
+
by_id[rid] = r
|
|
375
|
+
elif isinstance(rid, str) and rid.strip().rstrip(".") in canon:
|
|
376
|
+
r["symbol_id"] = canon[rid.strip().rstrip(".")]
|
|
377
|
+
by_id[r["symbol_id"]] = r
|
|
378
|
+
except Exception as e:
|
|
379
|
+
if _is_infra_failure(e):
|
|
380
|
+
infra_exc = e
|
|
381
|
+
elif "exceeded" in str(e) and attempt == 1:
|
|
382
|
+
# A batch timeout is often provider slowness, not batch difficulty
|
|
383
|
+
# — one retry before conceding. A repeat floors (termination), it
|
|
384
|
+
# does not abort: a genuinely oversized batch must not wedge passes.
|
|
385
|
+
print(f"[extract-loop] model batch timeout ({e}) — retrying once "
|
|
386
|
+
f"in {int(_RETRY_PAUSE)}s", file=sys.stderr)
|
|
387
|
+
time.sleep(_RETRY_PAUSE)
|
|
388
|
+
continue
|
|
389
|
+
else:
|
|
390
|
+
print(f"[extract-loop] model batch failed ({e}); RISK-flooring the batch",
|
|
391
|
+
file=sys.stderr)
|
|
392
|
+
floor_batch = True
|
|
393
|
+
if floor_batch or by_id:
|
|
394
|
+
break
|
|
395
|
+
if infra_exc is None and len(take) < 4:
|
|
396
|
+
# Tiny batch, no objects, no classified error: could be one genuinely
|
|
397
|
+
# unstatable node — floor it rather than wedge the loop.
|
|
398
|
+
break
|
|
399
|
+
if attempt == 2:
|
|
400
|
+
# An outage is not a judgment. Flooring here would burn the whole
|
|
401
|
+
# remaining worklist into placeholders — stop the pass instead;
|
|
402
|
+
# nothing from this batch is written, resume re-derives it. NO
|
|
403
|
+
# parseable rule objects for a whole real batch is the same outage
|
|
404
|
+
# signature (notice printed with exit 0, or a wholesale empty array);
|
|
405
|
+
# per-unit objects with explicit empty statements are a JUDGMENT and
|
|
406
|
+
# floor as designed.
|
|
407
|
+
why = f"infra: {infra_exc}" if infra_exc else f"zero-yield ({len(take)} framed, 0 rule objects)"
|
|
408
|
+
print(f"[extract-loop] {why} — persisted after retry; pass aborted, batch NOT floored",
|
|
409
|
+
file=sys.stderr)
|
|
410
|
+
return 75 # EX_TEMPFAIL
|
|
411
|
+
print("[extract-loop] transient zero-yield/infra on batch — retrying once "
|
|
412
|
+
f"in {int(_RETRY_PAUSE)}s", file=sys.stderr)
|
|
413
|
+
time.sleep(_RETRY_PAUSE)
|
|
173
414
|
|
|
174
415
|
# Deterministic decision + write per node — RISK-FLOOR: every node terminates.
|
|
175
416
|
for n in take:
|
|
@@ -182,6 +423,14 @@ def run(db: str, *, time_budget: float, limit: int, batch: int, dry_run: bool,
|
|
|
182
423
|
resolved = adjusted >= RESOLVE_THRESHOLD
|
|
183
424
|
_write_node(estate, sid, name, rule, resolved,
|
|
184
425
|
"below confidence threshold" if not resolved else "")
|
|
426
|
+
elif (rule or {}).get("statement"):
|
|
427
|
+
_write_node(estate, sid, name, rule, False, reason)
|
|
428
|
+
elif _has_statement(store, sid):
|
|
429
|
+
# Statement-less floor over existing content would DEGRADE the store —
|
|
430
|
+
# a re-queued node keeps its prior statement instead. Floors only ever
|
|
431
|
+
# fill blanks; content is monotonic.
|
|
432
|
+
print(f"[extract-loop] floor skipped for {sid} (existing statement kept)",
|
|
433
|
+
file=sys.stderr)
|
|
185
434
|
else:
|
|
186
435
|
_write_node(estate, sid, name, rule, False, reason)
|
|
187
436
|
processed += 1
|
|
@@ -198,11 +447,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
198
447
|
ap.add_argument("--limit", type=int, default=0, help="max nodes this pass (0 = unbounded within time budget)")
|
|
199
448
|
ap.add_argument("--batch", type=int, default=12, help="framed nodes per model call")
|
|
200
449
|
ap.add_argument("--dry-run", action="store_true", help="deterministic stub instead of the model (zero cost)")
|
|
450
|
+
ap.add_argument("--cluster-offset", type=int, default=0,
|
|
451
|
+
help="parallel workers: take the Nth leverage-ranked cluster instead of the head")
|
|
201
452
|
args = ap.parse_args(argv)
|
|
202
453
|
if not args.db:
|
|
203
454
|
ap.error("--db is required (or set $WICKED_ESTATE_DB)")
|
|
204
455
|
return run(args.db, time_budget=args.time_budget, limit=args.limit,
|
|
205
|
-
batch=args.batch, dry_run=args.dry_run)
|
|
456
|
+
batch=args.batch, dry_run=args.dry_run, cluster_offset=args.cluster_offset)
|
|
206
457
|
|
|
207
458
|
|
|
208
459
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Direct-API drop-in for the rule-extraction model boundary.
|
|
3
|
+
|
|
4
|
+
The harness talks to its model via an argv that reads the prompt on stdin and
|
|
5
|
+
prints the response on stdout (`claude -p` shape). Each CLI invocation pays
|
|
6
|
+
full agent bootstrap for what is a single completion; this adapter makes the
|
|
7
|
+
same one-shot call straight against the Anthropic API — no bootstrap, explicit
|
|
8
|
+
prompt caching, stdlib-only (urllib, no SDK).
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
export ANTHROPIC_API_KEY=... # required
|
|
12
|
+
export WICKED_RULE_MODEL_BIN="python3 /path/to/rule_model_api.py"
|
|
13
|
+
# optional: WICKED_RULE_MODEL_API_MODEL (default claude-haiku-4-5-20251001)
|
|
14
|
+
|
|
15
|
+
The prompt's static instruction header (everything before the first
|
|
16
|
+
"--- UNIT 1 ---") is sent as a system block with cache_control so every call
|
|
17
|
+
shares the cached prefix; only the framed units vary.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
|
|
27
|
+
_API_URL = "https://api.anthropic.com/v1/messages"
|
|
28
|
+
_UNIT_BOUNDARY = "\n--- UNIT 1 ---\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> int:
|
|
32
|
+
key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
33
|
+
if not key:
|
|
34
|
+
print("rule_model_api: ANTHROPIC_API_KEY is not set", file=sys.stderr)
|
|
35
|
+
return 78 # EX_CONFIG
|
|
36
|
+
model = os.environ.get("WICKED_RULE_MODEL_API_MODEL", "claude-haiku-4-5-20251001")
|
|
37
|
+
prompt = sys.stdin.read()
|
|
38
|
+
|
|
39
|
+
cut = prompt.find(_UNIT_BOUNDARY)
|
|
40
|
+
if cut > 0:
|
|
41
|
+
system_part, user_part = prompt[:cut], prompt[cut:]
|
|
42
|
+
else: # unexpected shape — send everything as the user turn, no cached prefix
|
|
43
|
+
system_part, user_part = "", prompt
|
|
44
|
+
|
|
45
|
+
body: dict = {
|
|
46
|
+
"model": model,
|
|
47
|
+
"max_tokens": 8192,
|
|
48
|
+
"messages": [{"role": "user", "content": user_part}],
|
|
49
|
+
}
|
|
50
|
+
if system_part:
|
|
51
|
+
body["system"] = [{"type": "text", "text": system_part,
|
|
52
|
+
"cache_control": {"type": "ephemeral"}}]
|
|
53
|
+
|
|
54
|
+
req = urllib.request.Request(
|
|
55
|
+
_API_URL,
|
|
56
|
+
data=json.dumps(body).encode("utf-8"),
|
|
57
|
+
headers={
|
|
58
|
+
"content-type": "application/json",
|
|
59
|
+
"x-api-key": key,
|
|
60
|
+
"anthropic-version": "2023-06-01",
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=170) as resp:
|
|
65
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
66
|
+
except urllib.error.HTTPError as e:
|
|
67
|
+
detail = e.read().decode("utf-8", errors="replace")[:300]
|
|
68
|
+
# Mirror the CLI failure shape the harness classifies on: rate/overloaded/
|
|
69
|
+
# auth markers in the message route to the infra abort, not the floor.
|
|
70
|
+
print(f"rule_model_api: HTTP {e.code} — {detail}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
73
|
+
print(f"rule_model_api: network failure — {e}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
text = "".join(b.get("text", "") for b in payload.get("content", [])
|
|
77
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
78
|
+
if not text.strip():
|
|
79
|
+
print(f"rule_model_api: empty completion (stop_reason={payload.get('stop_reason')!r})",
|
|
80
|
+
file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
sys.stdout.write(text)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main())
|