codebase-explorer 2.2.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.
Files changed (47) hide show
  1. cbe/__init__.py +9 -0
  2. cbe/__main__.py +4 -0
  3. cbe/aggregation.py +183 -0
  4. cbe/api_completion.py +410 -0
  5. cbe/batching.py +283 -0
  6. cbe/catalog_query.py +376 -0
  7. cbe/checks.py +390 -0
  8. cbe/cli.py +434 -0
  9. cbe/concrete_facts.py +321 -0
  10. cbe/data/LICENSE.tiktoken +30 -0
  11. cbe/data/o200k_base.tiktoken +199998 -0
  12. cbe/declarations.py +537 -0
  13. cbe/docs.py +929 -0
  14. cbe/generate.py +316 -0
  15. cbe/grammars.py +741 -0
  16. cbe/graph.py +857 -0
  17. cbe/graph_backends/__init__.py +43 -0
  18. cbe/graph_backends/_calls.py +262 -0
  19. cbe/graph_backends/_paths.py +42 -0
  20. cbe/graph_backends/identity.py +93 -0
  21. cbe/graph_backends/imports.py +256 -0
  22. cbe/graph_backends/joern.py +1021 -0
  23. cbe/graph_backends/resolution.py +288 -0
  24. cbe/graph_backends/toolchain.py +396 -0
  25. cbe/grounding.py +57 -0
  26. cbe/host_run.py +1752 -0
  27. cbe/model_settings.py +80 -0
  28. cbe/projection.py +305 -0
  29. cbe/prompts.py +316 -0
  30. cbe/records.py +200 -0
  31. cbe/skeleton.py +474 -0
  32. cbe/source_context.py +709 -0
  33. cbe/symbols.py +136 -0
  34. cbe/token_budget.py +68 -0
  35. cbe/treename.py +398 -0
  36. cbe/usage.py +300 -0
  37. cbe_install/__init__.py +11 -0
  38. cbe_install/cli.py +434 -0
  39. cbe_install/resources/agents/cbe-writer.md +16 -0
  40. cbe_install/resources/codebase-explorer/SKILL.md +101 -0
  41. cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
  42. cbe_install/resources/codebase-explorer/references/codex.md +37 -0
  43. codebase_explorer-2.2.0.dist-info/METADATA +233 -0
  44. codebase_explorer-2.2.0.dist-info/RECORD +47 -0
  45. codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
  46. codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
  47. codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
cbe/host_run.py ADDED
@@ -0,0 +1,1752 @@
1
+ """Durable run state shared by both generation drivers: plan, schedule, check, render, resume.
2
+
3
+ ``cbe host`` lets a host agent (Claude Code, Codex, ...) dispatch one subagent
4
+ per task; ``cbe generate`` runs the same tasks as tool-free OpenAI-compatible
5
+ API completions. Either way this state machine keeps every deterministic
6
+ step: freezing the source context, reading batches (``batching``), the unit
7
+ inventory (``symbols``), task order and leases, result checks (``checks``),
8
+ rendering and structural verification (``docs``). Task bodies come from
9
+ ``prompts``; usage and cost reports from ``usage``.
10
+
11
+ The runtime records are the resume point. ``<run>/host/state.json`` holds the
12
+ frozen plan and every task's state, attempts, timings, and output path;
13
+ ``<run>/progress.log`` gets one human-readable line per event; and
14
+ ``<run>/STATUS.md`` is a snapshot table rewritten on every change. Every command
15
+ re-reads the state under a lock, so any host continues with ``resume``.
16
+
17
+ No single task failure voids a run. Detail tasks have three answer attempts;
18
+ other tasks have two. A detail answer is decoded unit by unit: what is usable is
19
+ kept, and a re-ask lists only the gaps (symbols with logic that no unit explains,
20
+ units that were rejected). After the last attempt the remaining gaps are indexed
21
+ without prose and flagged ``unexplained``; the task still completes.
22
+
23
+ The module tree is made by the program: once all detail is settled ``skeleton.build``
24
+ freezes its structure into the run state, and ``tree_name_<k>`` tasks (batches of leaf
25
+ modules) and one ``tree_top`` task (all internal nodes) only name it (``treename``). Those
26
+ answers are decoded node by node like detail answers; a node that stays unnamed after the
27
+ last attempt gets a program-made title and the task still completes.
28
+
29
+ Other tasks are re-asked with the exact error, and a usable final answer is normalized and
30
+ flagged. Only an unusable final answer marks a task failed. Finished batches always
31
+ render; failed parts stay pending until retried, and the overview task can be
32
+ retried on its own.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+ import gzip
39
+ import hashlib
40
+ import json
41
+ import os
42
+ import re
43
+ import secrets
44
+ import time
45
+ from collections import Counter
46
+ from datetime import datetime, timezone
47
+ from pathlib import Path
48
+ from typing import Any, Iterator
49
+
50
+ from cbe import aggregation, batching, catalog_query, checks, docs, grounding, projection, prompts, skeleton, symbols, treename
51
+ from cbe import __version__
52
+ from cbe import source_context
53
+ from cbe.grammars import ContextError
54
+ from cbe.graph import check_graph_source, read_graph_quality
55
+ from cbe.token_budget import count_text_tokens
56
+
57
+
58
+ SCHEMA = "cbe-host-run/3"
59
+ RUN_VERSION_ERROR = catalog_query.RUN_VERSION_ERROR
60
+ MAX_ATTEMPTS = 2
61
+ DETAIL_MAX_ATTEMPTS = 3
62
+ REVIEW_MODES = ("none", "sample", "all")
63
+ # One switch for both drivers: change to "all" to review every module by default.
64
+ DEFAULT_REVIEW = "sample"
65
+ TREE_KINDS = ("tree_name", "tree_top")
66
+ FALLBACK_CODES = ("source_reassembly_fallback", "context_fallback") # diagnostics kept in the run state
67
+ ORDER = {"module": 1, "repair": 2, "tree_name": 3, "tree_top": 3, "review": 4, "system": 5}
68
+
69
+
70
+ class HostRunError(ValueError):
71
+ pass
72
+
73
+
74
+ def _now() -> str:
75
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
76
+
77
+
78
+ def epoch(stamp: str) -> float:
79
+ return datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
80
+
81
+
82
+ def state_dir(run_dir: Path) -> Path:
83
+ return run_dir / "host"
84
+
85
+
86
+ def write_json(path: Path, value: Any) -> None:
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
89
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
90
+ os.replace(temporary, path)
91
+
92
+
93
+ class _RunState(dict[str, Any]):
94
+ # Cache only within one locked operation; attributes are never serialized.
95
+ context: dict[str, Any] | None = None
96
+ aggregation_cache: tuple | None = None
97
+ tree_cache: tuple | None = None
98
+
99
+
100
+ def context_digest(context: dict[str, Any]) -> str:
101
+ """Canonical digest of a frozen context object.
102
+
103
+ Sorted keys and fixed separators make the digest reproducible across
104
+ processes and platforms, so a rewritten ``context.json`` cannot pass as
105
+ the frozen snapshot."""
106
+ payload = json.dumps(context, ensure_ascii=False, sort_keys=True,
107
+ separators=(",", ":")).encode("utf-8")
108
+ return hashlib.sha256(payload).hexdigest()
109
+
110
+
111
+ def _verify_graph_manifest(repo: Path, state: dict[str, Any], context: dict[str, Any]) -> None:
112
+ """Re-check that the frozen graph still matches the planned source (see ``check_graph_source``)."""
113
+ graph = context.get("graph")
114
+ if not graph:
115
+ return
116
+ try:
117
+ check_graph_source(repo, graph, {row["path"]: row["digest"] for row in state["files"]})
118
+ except ContextError as exc:
119
+ raise HostRunError(f"frozen graph does not match the planned source: {exc.message}; "
120
+ "start a new plan") from exc
121
+
122
+
123
+ def _verified_context(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]:
124
+ """Return the frozen context only if it is byte-identical to the plan.
125
+
126
+ Runs before every prompt, dispatch, submission, resume and verification, so
127
+ a rewritten ``context.json`` cannot smuggle a forged graph, chunk or
128
+ manifest into a model prompt or into evidence."""
129
+ path = run_dir / "context.json"
130
+ if not path.is_file():
131
+ raise HostRunError(f"no frozen source context at {path}")
132
+ raw = path.read_bytes()
133
+ raw_digest = hashlib.sha256(raw).hexdigest()
134
+ frozen = state.get("context") or {}
135
+ if raw_digest != frozen.get("raw_digest"):
136
+ if frozen.get("raw_digest") is None:
137
+ raise HostRunError(RUN_VERSION_ERROR)
138
+ raise HostRunError("frozen source context was modified after planning; start a new plan")
139
+ context = state.context if isinstance(state, _RunState) else None
140
+ if context is None:
141
+ context = json.loads(raw)
142
+ if context.get("schema") != frozen.get("schema"):
143
+ raise HostRunError("frozen context schema changed after planning; start a new plan")
144
+ if bool(context.get("graph")) != bool(frozen.get("graph_present")):
145
+ raise HostRunError("frozen context graph presence changed after planning; start a new plan")
146
+ planned = {row["path"]: row["digest"] for row in state["files"]}
147
+ context_files = {row.get("path"): row.get("sha256") for row in context.get("files") or []}
148
+ for path, digest in planned.items():
149
+ if context_files.get(path) != digest:
150
+ raise HostRunError(f"frozen context and plan disagree on {path}; start a new plan")
151
+ _verify_graph_manifest(Path(state["repo"]), state, context)
152
+ if isinstance(state, _RunState):
153
+ state.context = context
154
+ return context
155
+
156
+
157
+ def _build_context(repo: Path, paths: list[str],
158
+ graph_path: Path | None = None) -> dict[str, Any]:
159
+ """Freeze the source context once through the packaged product module.
160
+
161
+ ``cbe.source_context`` is a required product module; there is no second
162
+ context implementation. A graph path is validated against the frozen
163
+ source by ``build_context``.
164
+ """
165
+ try:
166
+ return source_context.build_context(repo, paths, graph_path=graph_path)
167
+ except Exception as exc: # noqa: BLE001 - surface a locatable context/graph failure
168
+ raise HostRunError(f"source context build failed: {exc}") from exc
169
+
170
+
171
+ def _semantic_units(run_dir: Path, state: dict[str, Any]) -> tuple[dict[str, dict], dict[str, dict]]:
172
+ """Merge accepted module/repair detail payloads into (units, dispositions).
173
+
174
+ A repair output supersedes its module's original output, matching
175
+ ``_module_output``; unit ids are keyed once and a cross-batch duplicate is
176
+ caught by the deterministic checks rather than silently overwritten."""
177
+ units: dict[str, dict] = {}
178
+ dispositions: dict[str, dict] = {}
179
+ for row in state["modules"]:
180
+ output = _module_output(run_dir, state, row["number"])
181
+ if not output:
182
+ continue
183
+ for unit in output.get("units") or []:
184
+ uid = unit.get("id") if isinstance(unit, dict) else None
185
+ if uid and uid not in units:
186
+ units[uid] = unit
187
+ for key, entry in (output.get("dispositions") or {}).items():
188
+ dispositions.setdefault(key, entry)
189
+ return units, dispositions
190
+
191
+
192
+ def _tree_names(run_dir: Path, state: dict[str, Any]) -> dict[str, dict]:
193
+ """Records of every node a finished tree task has named, by node id."""
194
+ names: dict[str, dict] = {}
195
+ for tid, task in state["tasks"].items():
196
+ if task["kind"] in TREE_KINDS and task["status"] == "done":
197
+ names.update((_accepted(run_dir, tid) or {}).get("names") or {})
198
+ return names
199
+
200
+
201
+ def _semantic_tree(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]:
202
+ """The tree as far as it is named: the frozen skeleton with the accepted names, program-made
203
+ titles for the rest. Before the skeleton is frozen it is built from the units finished so far."""
204
+ records, edges = _aggregation_inputs(run_dir, state)
205
+ fingerprint = _detail_fingerprint(run_dir, state)
206
+ frozen = state.get("skeleton")
207
+ if frozen and frozen["input_digest"] == fingerprint:
208
+ nodes = frozen["nodes"]
209
+ else:
210
+ cached = getattr(state, "tree_cache", None)
211
+ if cached is None or cached[0] != fingerprint:
212
+ cached = fingerprint, skeleton.build(records, edges)["nodes"]
213
+ if isinstance(state, _RunState):
214
+ state.tree_cache = cached
215
+ nodes = cached[1]
216
+ if not nodes:
217
+ return treename.empty_tree()
218
+ return treename.assemble(treename.Tree(nodes), _tree_names(run_dir, state), records, edges,
219
+ Path(state["repo"]).name)
220
+
221
+
222
+ def _detail_fingerprint(run_dir: Path, state: dict[str, Any]) -> str:
223
+ digest = hashlib.sha256()
224
+ for row in state["modules"]:
225
+ tid = f"repair_{row['number'] + 1}"
226
+ if state["tasks"].get(tid, {}).get("status") != "done":
227
+ tid = f"module_{row['number'] + 1}"
228
+ digest.update(f"{tid}:{state['tasks'][tid]['status']}".encode())
229
+ path = state_dir(run_dir) / "accepted" / f"{tid}.json"
230
+ if state["tasks"][tid]["status"] == "done" and path.is_file():
231
+ digest.update(path.read_bytes())
232
+ return digest.hexdigest()
233
+
234
+
235
+ def _aggregation_inputs(run_dir: Path, state: dict[str, Any]) -> tuple[dict, list[dict]]:
236
+ fingerprint = _detail_fingerprint(run_dir, state)
237
+ cached = getattr(state, "aggregation_cache", None)
238
+ if cached and cached[0] == fingerprint:
239
+ return cached[1]
240
+ cache_path = state_dir(run_dir) / "aggregation-inputs.json"
241
+ compressed_path = cache_path.with_suffix(".json.gz")
242
+ payload = None
243
+ if compressed_path.is_file() or cache_path.is_file():
244
+ stored_path = compressed_path if compressed_path.is_file() else cache_path
245
+ raw = stored_path.read_bytes()
246
+ metadata = state.get("aggregation_cache") or {}
247
+ if (metadata.get("fingerprint") == fingerprint and
248
+ metadata.get("content_digest") == hashlib.sha256(raw).hexdigest()):
249
+ cached = json.loads(gzip.decompress(raw) if stored_path == compressed_path else raw)
250
+ if cached["fingerprint"] == fingerprint:
251
+ payload = cached["records"], cached["edges"]
252
+ if payload is None:
253
+ units, dispositions = _semantic_units(run_dir, state)
254
+ payload = aggregation.inputs(units, dispositions, state["inventory"], _verified_context(run_dir, state))
255
+ serialized = json.dumps({"fingerprint": fingerprint, "records": payload[0], "edges": payload[1]},
256
+ ensure_ascii=False).encode("utf-8")
257
+ if len(serialized) > 10_000_000:
258
+ stored_path = compressed_path
259
+ temporary = compressed_path.with_suffix(".tmp")
260
+ temporary.write_bytes(gzip.compress(serialized, mtime=0))
261
+ os.replace(temporary, compressed_path)
262
+ cache_path.unlink(missing_ok=True)
263
+ else:
264
+ stored_path = cache_path
265
+ temporary = cache_path.with_suffix(".tmp")
266
+ temporary.write_bytes(serialized)
267
+ os.replace(temporary, cache_path)
268
+ compressed_path.unlink(missing_ok=True)
269
+ state["aggregation_cache"] = {"fingerprint": fingerprint,
270
+ "content_digest": hashlib.sha256(stored_path.read_bytes()).hexdigest()}
271
+ if isinstance(state, _RunState):
272
+ state.aggregation_cache = fingerprint, payload
273
+ return payload
274
+
275
+
276
+ def _wall_seconds(run_dir: Path, state: dict[str, Any]) -> float:
277
+ """Time from the plan to this render; a run that was already complete keeps the time it finished in."""
278
+ try:
279
+ previous = json.loads((run_dir / "summary.json").read_text(encoding="utf-8"))
280
+ except (OSError, json.JSONDecodeError):
281
+ previous = {}
282
+ if previous.get("status") == "complete" and type(previous.get("wall_seconds")) in (int, float):
283
+ return previous["wall_seconds"]
284
+ return round(time.time() - epoch(state["created_at"]), 3)
285
+
286
+
287
+ def _invalidate_tree(run_dir: Path, state: dict[str, Any]) -> None:
288
+ """Drop the frozen skeleton, its naming tasks and their answers (the detail changed)."""
289
+ for tid in [tid for tid, task in state["tasks"].items() if task["kind"] in TREE_KINDS]:
290
+ del state["tasks"][tid]
291
+ (state_dir(run_dir) / "accepted" / f"{tid}.json").unlink(missing_ok=True)
292
+ _partial_path(run_dir, tid).unlink(missing_ok=True)
293
+ state.pop("skeleton", None)
294
+
295
+
296
+ def _prepare_tree(run_dir: Path, state: dict[str, Any]) -> None:
297
+ """Once all detail and review is settled, freeze the skeleton and make the naming tasks.
298
+
299
+ The same detail finds the same skeleton in the run state; changed detail (a late module, a
300
+ repair) drops it and starts over."""
301
+ tasks = state["tasks"]
302
+ if not _done_modules(state) or not all(
303
+ _settled(state, task) for task in tasks.values() if task["kind"] in {"module", "repair", "review"}):
304
+ return
305
+ fingerprint = _detail_fingerprint(run_dir, state)
306
+ if (state.get("skeleton") or {}).get("input_digest") == fingerprint:
307
+ return
308
+ units, edges = _aggregation_inputs(run_dir, state)
309
+ if not units and not state.get("skeleton"):
310
+ return # an empty run: _auto_complete_empty_modules published its one-node tree
311
+ _invalidate_tree(run_dir, state)
312
+ if not units:
313
+ return
314
+ frozen = {**treename.freeze(units, edges), "input_digest": fingerprint}
315
+ state["skeleton"] = frozen
316
+ for number in range(len(frozen["batches"])):
317
+ tid = f"tree_name_{number + 1}"
318
+ tasks[tid] = _task(tid, "tree_name", batch=number)
319
+ parents = {node["parent"] for node in frozen["nodes"]}
320
+ leaves = sum(node["id"] not in parents for node in frozen["nodes"])
321
+ if leaves > 1:
322
+ tasks["tree_top"] = _task("tree_top", "tree_top")
323
+ log_event(run_dir, "queue", f"module skeleton frozen: {leaves} modules in {len(frozen['nodes'])} nodes; "
324
+ f"{len(frozen['batches'])} naming batch(es)" + (", then tree_top" if leaves > 1 else ""),
325
+ leaves=leaves, nodes=len(frozen["nodes"]), batches=len(frozen["batches"]))
326
+
327
+
328
+ def _known_unit_ids(run_dir: Path, state: dict[str, Any], *, exclude: int | None = None) -> set[str]:
329
+ units, _ = _semantic_units(run_dir, state)
330
+ if exclude is None:
331
+ return set(units)
332
+ paths = set(state["modules"][exclude]["files"])
333
+ # A repair supersedes the module's own output, so that output's unit ids are
334
+ # not foreign collisions. Path-prefixed ids drop out via ``paths``; group
335
+ # (``grp:``) ids carry no path, so subtract the module's current output ids
336
+ # explicitly. Cross-module collisions are still reported.
337
+ own = _module_output(run_dir, state, exclude) or {}
338
+ own_ids = {unit.get("id") for unit in own.get("units") or [] if isinstance(unit, dict)}
339
+ return {uid for uid in units if uid.split("::", 1)[0] not in paths and uid not in own_ids}
340
+
341
+
342
+ def _module_inventory_ids(state: dict[str, Any], module: dict[str, Any]) -> list[str]:
343
+ inventory = state.get("inventory") or {}
344
+ return [symbol["id"]
345
+ for path in module["files"]
346
+ for symbol in (inventory.get(path) or {}).get("symbols") or []]
347
+
348
+
349
+ def _auto_complete_empty_modules(run_dir: Path, state: dict[str, Any]) -> list[str]:
350
+ """Complete a known-empty batch mechanically, without a model call.
351
+
352
+ A module whose files hold no inventory id has no explainable detail: it may
353
+ only be empty files. Writing an empty, explicit accepted payload prevents
354
+ both a ``units must be a nonempty list`` rejection and the invented prose
355
+ that a forced retry produces. Comments and docstrings still produce chunks
356
+ and inventory ids, so they are never auto-completed."""
357
+ completed: list[str] = []
358
+ for row in state["modules"]:
359
+ number = row["number"]
360
+ task = state["tasks"].get(f"module_{number + 1}")
361
+ if task is None or task["status"] != "pending" or _module_inventory_ids(state, row):
362
+ continue
363
+ output = {"units": [], "dispositions": {},
364
+ "batch_notes": "No explainable detail: every file in this batch is empty and has "
365
+ "no inventory id, so no model call was made.",
366
+ "known_empty": True}
367
+ write_json(state_dir(run_dir) / "accepted" / f"module_{number + 1}.json", output)
368
+ task["status"], task["flags"] = "done", ["known_empty"]
369
+ task["output"] = f"host/accepted/module_{number + 1}.json"
370
+ log_event(run_dir, "accept", f"module_{number + 1} auto-completed: known-empty batch, no model call",
371
+ task=task["id"], flags=["known_empty"])
372
+ completed.append(task["id"])
373
+ review = state["tasks"].get(f"review_{number + 1}")
374
+ if review is not None and review["status"] == "pending":
375
+ write_json(state_dir(run_dir) / "accepted" / f"review_{number + 1}.json",
376
+ {"decision": "accepted", "issues": []})
377
+ review["status"], review["output"] = "done", f"host/accepted/review_{number + 1}.json"
378
+ log_event(run_dir, "accept", f"review_{number + 1} auto-accepted: known-empty module has no "
379
+ "prose to review", task=review["id"])
380
+ modules = [task for task in state["tasks"].values() if task["kind"] == "module"]
381
+ if modules and all(task["status"] in {"done", "failed"} for task in modules):
382
+ # Only once every reading batch is settled can "no units" mean an empty
383
+ # repository; before that the real units are still in flight.
384
+ units, _ = _semantic_units(run_dir, state)
385
+ if not units:
386
+ system = state["tasks"].get("system")
387
+ if system is not None and system["status"] == "pending":
388
+ write_json(state_dir(run_dir) / "accepted" / "system.json",
389
+ _mechanical_semantic_overview(state, {"nodes": []}))
390
+ system["status"], system["output"] = "done", "host/accepted/system.json"
391
+ log_event(run_dir, "accept", "system auto-completed mechanically: no units in this "
392
+ "run", task="system")
393
+ return completed
394
+
395
+
396
+ def _assert_paths_unchanged(state: dict[str, Any], paths: list[str]) -> None:
397
+ """The script driver builds a prompt outside ``next_tasks``; a drifted source
398
+ must stop it exactly as the host dispatch refuses a drifted path."""
399
+ changed = _drift(state, paths)
400
+ if changed:
401
+ raise HostRunError(f"source changed since plan: {changed[0]}; start a new plan")
402
+
403
+
404
+ @contextlib.contextmanager
405
+ def locked_state(run_dir: Path) -> Iterator[dict[str, Any]]:
406
+ """Yield the state under an exclusive lock; save it and STATUS.md on normal exit."""
407
+ host = state_dir(run_dir)
408
+ state_path = host / "state.json"
409
+ if not state_path.is_file():
410
+ raise HostRunError(f"no run state at {state_path}; run `cbe host plan` first")
411
+ with open(host / ".lock", "a+", encoding="utf-8") as handle:
412
+ try:
413
+ import fcntl
414
+ fcntl.flock(handle, fcntl.LOCK_EX)
415
+ except ImportError: # pragma: no cover - non-POSIX hosts run one command at a time
416
+ pass
417
+ state = _RunState(json.loads(state_path.read_text(encoding="utf-8")))
418
+ if state.get("schema") != SCHEMA:
419
+ raise HostRunError(RUN_VERSION_ERROR)
420
+ if any(task["kind"] in {"names", "tree"} for task in state["tasks"].values()):
421
+ raise HostRunError(RUN_VERSION_ERROR)
422
+ yield state
423
+ state["updated_at"] = _now()
424
+ write_json(state_path, state)
425
+ (run_dir / "STATUS.md").write_text(_status_markdown(state), encoding="utf-8")
426
+
427
+
428
+ def log_event(run_dir: Path, kind: str, text: str, **fields: Any) -> None:
429
+ """Append one machine record and one human-readable progress line."""
430
+ stamp = _now()
431
+ with (state_dir(run_dir) / "events.jsonl").open("a", encoding="utf-8") as stream:
432
+ stream.write(json.dumps({"at": stamp, "event": kind, **fields}, ensure_ascii=False) + "\n")
433
+ with (run_dir / "progress.log").open("a", encoding="utf-8") as stream:
434
+ stream.write(f"{stamp} {kind:<9} {text}\n")
435
+
436
+
437
+ def _attempt_limit(task: dict[str, Any]) -> int:
438
+ return DETAIL_MAX_ATTEMPTS if task["kind"] in {"module", "repair"} else MAX_ATTEMPTS
439
+
440
+
441
+ def _task(task_id: str, kind: str, module: int | None = None, **extra: Any) -> dict[str, Any]:
442
+ return {"id": task_id, "kind": kind, "module": module, "status": "pending", "attempts": 0,
443
+ "errors": [], "flags": [], "lease": None, "leased_at": None, "submitted_at": None,
444
+ "seconds": None, "output": None, "usage": [], **extra}
445
+
446
+
447
+ def _accepted(run_dir: Path, task_id: str) -> dict[str, Any] | None:
448
+ path = state_dir(run_dir) / "accepted" / f"{task_id}.json"
449
+ return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
450
+
451
+
452
+ def _module_task(state: dict[str, Any], number: int) -> dict[str, Any]:
453
+ return state["tasks"][f"module_{number + 1}"]
454
+
455
+
456
+ def _module_output(run_dir: Path, state: dict[str, Any], number: int) -> dict[str, Any] | None:
457
+ repair = state["tasks"].get(f"repair_{number + 1}")
458
+ if repair and repair["status"] == "done":
459
+ return _accepted(run_dir, repair["id"])
460
+ if _module_task(state, number)["status"] == "done":
461
+ return _accepted(run_dir, f"module_{number + 1}")
462
+ return None
463
+
464
+
465
+ def _done_modules(state: dict[str, Any]) -> list[int]:
466
+ return [row["number"] for row in state["modules"] if _module_task(state, row["number"])["status"] == "done"]
467
+
468
+
469
+ def _drift(state: dict[str, Any], paths: list[str] | None = None) -> list[str]:
470
+ """Planned paths that are missing, unreadable, or no longer match their digest."""
471
+ repo = Path(state["repo"])
472
+ wanted = set(paths) if paths is not None else None
473
+ changed = []
474
+ for row in state["files"]:
475
+ if wanted is not None and row["path"] not in wanted:
476
+ continue
477
+ path = repo / row["path"]
478
+ try:
479
+ raw = path.read_bytes()
480
+ except OSError:
481
+ # Missing or unreadable: a host task must not list a path the
482
+ # subagent cannot actually read.
483
+ changed.append(row["path"])
484
+ continue
485
+ if hashlib.sha256(raw).hexdigest() != row["digest"]:
486
+ changed.append(row["path"])
487
+ return changed
488
+
489
+
490
+ def _title(state: dict[str, Any], task: dict[str, Any]) -> str:
491
+ return state["modules"][task["module"]]["title"] if task["module"] is not None else ""
492
+
493
+
494
+ # ---------------------------------------------------------------- plan
495
+
496
+
497
+ def _runtime_environment() -> dict[str, Any]:
498
+ """Record declared distribution requirements, including optional dependencies."""
499
+ import importlib.metadata as metadata
500
+ import platform
501
+ import re
502
+ import sys
503
+
504
+ try:
505
+ requirements = metadata.requires("codebase-explorer") or []
506
+ except metadata.PackageNotFoundError:
507
+ # A source checkout can run without its own distribution metadata.
508
+ import tomllib
509
+ project = tomllib.loads((Path(__file__).parents[2] / "pyproject.toml").read_text(encoding="utf-8"))["project"]
510
+ requirements = list(project["dependencies"])
511
+ for extra in project.get("optional-dependencies", {}).values():
512
+ requirements.extend(extra)
513
+ dependencies = {}
514
+ for requirement in requirements:
515
+ name = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement).group()
516
+ try:
517
+ dependencies[name] = metadata.version(name)
518
+ except metadata.PackageNotFoundError:
519
+ dependencies[name] = None
520
+ return {"at": _now(), "python": sys.version, "platform": platform.platform(),
521
+ "cbe": __version__, "dependencies": dependencies}
522
+
523
+
524
+ def plan(repo: Path, run_dir: Path | None = None, *, review: str = DEFAULT_REVIEW, jobs: int = 4,
525
+ host: str = "other", driver: str = "host",
526
+ graph_path: Path | None = None, batch_target_tokens: int = batching.TARGET_TOKENS,
527
+ batch_max_tokens: int = batching.MAX_TOKENS) -> dict[str, Any]:
528
+ repo = repo.resolve()
529
+ if not repo.is_dir():
530
+ raise HostRunError("repository root is not a directory")
531
+ if review not in REVIEW_MODES:
532
+ raise HostRunError("review must be none, sample, or all")
533
+ if jobs < 1:
534
+ raise HostRunError("jobs must be positive")
535
+ if run_dir is None:
536
+ run_dir = repo / ".codebase-analysis" / "runs" / (
537
+ datetime.now(timezone.utc).strftime(f"{driver}-%Y%m%dT%H%M%SZ-") + secrets.token_hex(4))
538
+ run_dir = run_dir.resolve()
539
+ if (state_dir(run_dir) / "state.json").exists():
540
+ raise HostRunError("run directory already has a run; resume it or choose a fresh directory")
541
+ files = batching.scan(repo)
542
+ modules = batching.plan(files, target_tokens=batch_target_tokens, max_tokens=batch_max_tokens)
543
+ selected = [item.path for item in files]
544
+ context = _build_context(repo, selected, graph_path=graph_path)
545
+ symbols.ensure_chunk_coverage(context, repo, selected)
546
+ inventory = symbols.inventory_from_context(context, selected)
547
+ tasks: dict[str, dict[str, Any]] = {}
548
+ for module in modules:
549
+ tasks[f"module_{module.number + 1}"] = _task(f"module_{module.number + 1}", "module", module.number)
550
+ if review == "sample":
551
+ reviewed = [max(modules, key=lambda module: (module.tokens, -module.number))]
552
+ else:
553
+ reviewed = modules if review == "all" else []
554
+ for module in reviewed:
555
+ tasks[f"review_{module.number + 1}"] = _task(f"review_{module.number + 1}", "review", module.number)
556
+ tasks["system"] = _task("system", "system")
557
+ state = {
558
+ "schema": SCHEMA, "cbe_version": __version__, "run_tag": secrets.token_hex(3),
559
+ "repo": str(repo), "run_dir": str(run_dir), "host": host, "driver": driver,
560
+ "review": review, "jobs": jobs, "created_at": _now(), "updated_at": _now(), "status": "running",
561
+ "context": {"schema": context.get("schema"),
562
+ "graph_present": bool(context.get("graph")),
563
+ "graph_digest": context_digest(context["graph"]) if context.get("graph") else None},
564
+ "inventory": inventory,
565
+ "files": [{"path": item.path, "tokens": item.tokens, "digest": item.digest} for item in files],
566
+ "modules": [{"number": module.number, "bucket": module.bucket, "title": batching.fallback_title(module),
567
+ "files": [item.path for item in module.files]} for module in modules],
568
+ "tasks": tasks, "rendered": False, "verify": None,
569
+ "runtime_environments": [_runtime_environment()],
570
+ }
571
+ for code in FALLBACK_CODES:
572
+ fallbacks = [row for row in context.get("diagnostics", []) if row.get("code") == code]
573
+ if fallbacks:
574
+ state[code] = fallbacks
575
+ host_dir = state_dir(run_dir)
576
+ for name in ("tasks", "outbox", "accepted", "raw"):
577
+ (host_dir / name).mkdir(parents=True, exist_ok=True)
578
+ write_json(run_dir / "context.json", context)
579
+ state["context"]["raw_digest"] = hashlib.sha256((run_dir / "context.json").read_bytes()).hexdigest()
580
+ write_json(host_dir / "state.json", state)
581
+ (run_dir / "STATUS.md").write_text(_status_markdown(state), encoding="utf-8")
582
+ log_event(run_dir, "plan", f"{len(files)} files, {len(modules)} modules, {len(tasks)} tasks "
583
+ f"(review {review}, jobs {jobs}, host {host})",
584
+ modules=len(modules), files=len(files), review=review, jobs=jobs, host=host)
585
+ runs_root = repo / ".codebase-analysis" / "runs"
586
+ if run_dir.is_relative_to(runs_root):
587
+ (repo / ".codebase-analysis" / "host-latest").write_text(str(run_dir) + "\n", encoding="utf-8")
588
+ return {"run_dir": str(run_dir), "run_tag": state["run_tag"], "source_files": len(files),
589
+ "source_tokens": sum(item.tokens for item in files), "modules": len(modules),
590
+ "tasks": len(tasks), "review": review, "jobs": jobs,
591
+ "progress_log": str(run_dir / "progress.log"),
592
+ "next_action": f"cbe host next --run-dir {run_dir}"}
593
+
594
+
595
+ def update_graph(run_dir: Path, graph_path: Path) -> dict[str, Any]:
596
+ """Replace only the dataflow layer of a completed run's frozen graph."""
597
+ render_error = None
598
+ with locked_state(run_dir) as state:
599
+ if state["status"] != "complete" or any(task["status"] != "done" for task in state["tasks"].values()):
600
+ raise HostRunError("only a completed run with no pending or failed tasks can update its graph")
601
+ frozen = state["context"]
602
+ if not frozen.get("graph_present"):
603
+ raise HostRunError("this run was planned without a graph; start a new plan with --graph")
604
+ context = _verified_context(run_dir, state)
605
+ try:
606
+ graph = source_context._load_graph(
607
+ graph_path, Path(state["repo"]), {row["path"]: row["digest"] for row in state["files"]})
608
+ read_graph_quality(graph)
609
+ except (ContextError, ValueError, OSError) as exc:
610
+ raise HostRunError(f"cannot read the new graph: {exc}") from exc
611
+ old_graph = context["graph"]
612
+ old_digest = frozen["graph_digest"]
613
+ new_digest = context_digest(graph)
614
+ if new_digest == old_digest:
615
+ return {"status": "nothing to update", "graph_digest": old_digest}
616
+ if graph.get("source_manifest") != old_graph.get("source_manifest"):
617
+ raise HostRunError("the new graph has a different source_manifest; start a new plan")
618
+ old_structure = ([n for n in old_graph["nodes"] if n.get("kind") != "dataflow_node"],
619
+ [e for e in old_graph["edges"] if e.get("kind") != "dataflow"])
620
+ new_structure = ([n for n in graph["nodes"] if n.get("kind") != "dataflow_node"],
621
+ [e for e in graph["edges"] if e.get("kind") != "dataflow"])
622
+ if new_structure != old_structure:
623
+ raise HostRunError("the new graph differs from this run's graph in more than dataflow; start a new plan")
624
+ stamp = _now()
625
+ context["graph"] = graph
626
+ write_json(run_dir / "context.json", context)
627
+ frozen["raw_digest"] = hashlib.sha256((run_dir / "context.json").read_bytes()).hexdigest()
628
+ frozen["graph_digest"] = new_digest
629
+ state.context = context
630
+ state.setdefault("graph_updates", []).append(
631
+ {"at": stamp, "old_graph_digest": old_digest, "new_graph_digest": new_digest})
632
+ # context.json and its digest in the state change together: save now, so that a failure
633
+ # while rendering cannot leave a new context beside the old digest.
634
+ write_json(state_dir(run_dir) / "state.json", state)
635
+ log_event(run_dir, "graph", "replaced the frozen graph without rerunning tasks",
636
+ old_graph_digest=old_digest, new_graph_digest=new_digest)
637
+ _render(run_dir, state)
638
+ render_error = state.get("render_error")
639
+ result = {"status": "updated", "old_graph_digest": old_digest,
640
+ "new_graph_digest": new_digest, "updated_at": stamp}
641
+ if render_error:
642
+ raise HostRunError(f"graph was updated but rendering failed: {render_error}")
643
+ return result
644
+
645
+
646
+ def latest_run(repo: Path) -> Path:
647
+ pointer = repo.resolve() / ".codebase-analysis" / "host-latest"
648
+ if not pointer.is_file():
649
+ raise HostRunError(f"no run recorded under {repo}; pass --run-dir")
650
+ return Path(pointer.read_text(encoding="utf-8").strip())
651
+
652
+
653
+ # ---------------------------------------------------------------- scheduling
654
+
655
+
656
+ def _settled(state: dict[str, Any], task: dict[str, Any]) -> bool:
657
+ """Done, failed, or unable to run because the module it depends on failed."""
658
+ if task["status"] in {"done", "failed"}:
659
+ return True
660
+ if task["kind"] in {"review", "repair"}:
661
+ return _module_task(state, task["module"])["status"] == "failed"
662
+ if task["kind"] == "system":
663
+ others = [other for other in state["tasks"].values() if other["kind"] != "system"]
664
+ return all(_settled(state, other) for other in others) and not _done_modules(state)
665
+ return False
666
+
667
+
668
+ def _ready(state: dict[str, Any]) -> list[str]:
669
+ tasks = state["tasks"]
670
+ tree_names = [task for task in tasks.values() if task["kind"] == "tree_name"]
671
+ ready = []
672
+ for task_id, task in tasks.items():
673
+ if task["status"] != "pending":
674
+ continue
675
+ kind = task["kind"]
676
+ if kind in {"module", "repair", "tree_name"}:
677
+ # Detail prompts use only the frozen context and inventory; tree_name tasks exist only
678
+ # for a skeleton frozen after all detail and review settled.
679
+ ok = True
680
+ elif kind == "tree_top":
681
+ ok = all(other["status"] in {"done", "failed"} for other in tree_names)
682
+ elif kind == "review":
683
+ ok = _module_task(state, task["module"])["status"] == "done"
684
+ else:
685
+ others = [other for other in tasks.values() if other["kind"] != "system"]
686
+ ok = bool(_done_modules(state)) and bool(state.get("skeleton")) \
687
+ and all(_settled(state, other) for other in others)
688
+ if ok:
689
+ ready.append(task_id)
690
+ return sorted(ready, key=lambda task_id: (ORDER[tasks[task_id]["kind"]], tasks[task_id]["module"] or 0))
691
+
692
+
693
+ def _labels(state: dict[str, Any], task_id: str) -> dict[str, str]:
694
+ return {"description": f"cbe {state['run_tag']} {task_id}",
695
+ "codex_task_name": f"cbe_{state['run_tag']}_{task_id}"}
696
+
697
+
698
+ def _detail_task(run_dir: Path, state: dict[str, Any], task: dict[str, Any], *, embed: bool,
699
+ with_diagnostics: bool, issues: list[str] | None = None) -> str:
700
+ """The detail task for one reading batch, built from the verified frozen context."""
701
+ paths = list(state["modules"][task["module"]]["files"])
702
+ _assert_paths_unchanged(state, paths)
703
+ context = _verified_context(run_dir, state)
704
+ wanted = set(paths)
705
+ chunks = context.get("chunks") or []
706
+ diagnostics = [row for row in context.get("diagnostics") or [] if row.get("path") in wanted]
707
+ graph_file = None
708
+ graph = context.get("graph")
709
+ has_evidence = (projection.graph_state(graph)[0] == "provided"
710
+ and any(n.get("path") in wanted for n in graph.get("nodes", [])))
711
+ if not embed and state.get("driver") != "script" and has_evidence:
712
+ # The canonical detail file is shared with review and repair for this
713
+ # module: identical frozen evidence, independent of task id or issues.
714
+ target = state_dir(run_dir) / "tasks" / f"module_{task['module'] + 1}.graph.txt"
715
+ lines = projection.graph_prompt_lines(
716
+ context.get("graph"), paths, embed=False, inventory=state["inventory"],
717
+ chunks=chunks, source_tokens=None, max_tokens=None)
718
+ target.write_text("\n".join(lines), encoding="utf-8")
719
+ graph_file = str(target)
720
+ return prompts.detail_prompt(
721
+ paths, state["inventory"], chunks=chunks, source_root=Path(state["repo"]),
722
+ graph=context.get("graph"), graph_file=graph_file,
723
+ context_diagnostics=diagnostics if with_diagnostics else None, issues=issues, embed=embed)
724
+
725
+
726
+ def _system_inputs(run_dir: Path, state: dict[str, Any]) -> tuple[list[dict], int, dict, dict]:
727
+ """Use the same tree, evidence and character limit for prompt and acceptance."""
728
+ units, edges = _aggregation_inputs(run_dir, state)
729
+ tree = _semantic_tree(run_dir, state)
730
+ nodes = tree.get("nodes") or []
731
+ view = aggregation.relationship_view(tree, edges, _verified_context(run_dir, state).get("graph"))
732
+ return nodes, checks.system_limit(), view, units
733
+
734
+
735
+ def _body(run_dir: Path, state: dict[str, Any], task: dict[str, Any], *, embed: bool) -> tuple[str, str]:
736
+ """Return (rules, task body); ``embed`` puts source in the prompt for tool-free completions.
737
+
738
+ Script completions embed each file once (``embed=True``); host subagents get
739
+ the absolute paths and the authoritative chunk line ranges instead, so the
740
+ source is never sent twice. A missing graph is stated as not provided.
741
+ """
742
+ kind = task["kind"]
743
+ reading = prompts.NO_FILE_RULES if embed or state.get("driver") == "script" else prompts.FILE_TASK_RULES
744
+ no_source = prompts.NO_FILE_RULES
745
+ if not embed and state.get("driver") != "script":
746
+ no_source += (" Use your file-writing tool to write the result JSON. "
747
+ "Do not run shell commands, scripts, or interpreters, not even to assemble or check the JSON: "
748
+ "in the user's harness each one asks for permission, and this task does not need them.")
749
+ if kind in {"module", "repair"}:
750
+ return reading, _detail_task(run_dir, state, task, embed=embed, with_diagnostics=True,
751
+ issues=task.get("issues") if kind == "repair" else None)
752
+ if kind in TREE_KINDS:
753
+ return no_source, _tree_body(run_dir, state, task)
754
+ if kind == "review":
755
+ units = _module_output(run_dir, state, task["module"])
756
+ return reading, prompts.review_prompt(
757
+ units, _detail_task(run_dir, state, task, embed=embed, with_diagnostics=False))
758
+ nodes, limit, view, _ = _system_inputs(run_dir, state)
759
+ return no_source, prompts.system_prompt(nodes, limit, view)
760
+
761
+
762
+ def _tree_names_for_top(run_dir: Path, state: dict[str, Any], tree: "treename.Tree", records: dict) -> dict[str, dict]:
763
+ """Every leaf's record: the accepted one, or a program-made one when its naming task has not finished."""
764
+ names = _tree_names(run_dir, state)
765
+ for leaf in tree.leaves():
766
+ if leaf not in names:
767
+ names[leaf] = treename.program_record(records, tree.members(leaf))
768
+ return names
769
+
770
+
771
+ def _tree_body(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> str:
772
+ records, edges = _aggregation_inputs(run_dir, state)
773
+ tree = treename.Tree(state["skeleton"]["nodes"])
774
+ if task["kind"] == "tree_name":
775
+ batch = state["skeleton"]["batches"][task["batch"]]
776
+ return prompts.tree_name_prompt(treename.name_blocks(batch, tree, records, edges))
777
+ names = _tree_names_for_top(run_dir, state, tree, records)
778
+ return prompts.tree_top_prompt(Path(state["repo"]).name, treename.top_blocks(tree, names, edges))
779
+
780
+
781
+ def _previous_error(task: dict[str, Any]) -> str:
782
+ if not task["errors"]:
783
+ return ""
784
+ return ("\n\nA previous attempt failed validation. Correct it now. Original error: "
785
+ + task["errors"][-1][:2000] + "\n")
786
+
787
+
788
+ def _prompt(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> str:
789
+ rules, body = _body(run_dir, state, task, embed=False)
790
+ outbox = state_dir(run_dir) / "outbox" / f"{task['id']}.json"
791
+ return (f"# Codebase Explorer task `{task['id']}` (run {state['run_tag']})\n\n{rules}\n\n"
792
+ f"When you are done, write exactly one JSON object (no Markdown fences, no commentary) to this "
793
+ f"file, creating or replacing it:\n{outbox}\n\nThen reply with the single line: DONE {task['id']}\n"
794
+ f"{_retry_note(run_dir, state, task) or _previous_error(task)}\n---\n\n{body}")
795
+
796
+
797
+ def completion_prompt(run_dir: Path, task_id: str) -> str:
798
+ """The same task as one tool-free completion: source is embedded."""
799
+ with locked_state(run_dir) as state:
800
+ task = state["tasks"][task_id]
801
+ _, body = _body(run_dir, state, task, embed=True)
802
+ prompt = prompts.TOOL_FREE_RULES + "\n\n" + body
803
+ note = _retry_note(run_dir, state, task)
804
+ if note is not None:
805
+ prompt += note # the first-ask text stays byte-identical, so the provider's prefix cache can serve it
806
+ elif task["errors"]:
807
+ prompt += "\n\nPrevious completion failed. Correct it now. Original error: " + task["errors"][-1][:2000]
808
+ previous = state_dir(run_dir) / "raw" / f"{task_id}.attempt-{task['attempts']}.txt"
809
+ if previous.is_file():
810
+ prompt += "; original_output:" + previous.read_text(encoding="utf-8")[:8000]
811
+ return prompt
812
+
813
+
814
+ def next_tasks(run_dir: Path, *, limit: int | None = None, owner: str | None = None,
815
+ reclaim_after: int | None = None) -> dict[str, Any]:
816
+ with locked_state(run_dir) as state:
817
+ _verified_context(run_dir, state)
818
+ _auto_complete_empty_modules(run_dir, state)
819
+ _prepare_tree(run_dir, state)
820
+ now = time.time()
821
+ if reclaim_after is not None:
822
+ for task in state["tasks"].values():
823
+ if task["status"] == "leased" and now - epoch(task["lease"]["at"]) > reclaim_after:
824
+ task["status"], task["lease"] = "pending", None
825
+ log_event(run_dir, "reclaim", f"{task['id']} lease older than {reclaim_after}s returned to queue",
826
+ task=task["id"])
827
+ active = sum(task["status"] == "leased" for task in state["tasks"].values())
828
+ slots = max(0, state["jobs"] - active)
829
+ if limit is not None:
830
+ slots = min(slots, limit)
831
+ handed = []
832
+ queue = _ready(state)[:slots]
833
+ for task_id in queue:
834
+ task = state["tasks"][task_id]
835
+ if task["kind"] in {"module", "repair", "review"}:
836
+ changed = _drift(state, state["modules"][task["module"]]["files"])
837
+ if changed:
838
+ raise HostRunError("source missing or changed since plan: " + ", ".join(changed[:10])
839
+ + "; start a new plan")
840
+ prompt_path = state_dir(run_dir) / "tasks" / f"{task_id}.md"
841
+ prompt_path.write_text(_prompt(run_dir, state, task), encoding="utf-8")
842
+ (state_dir(run_dir) / "outbox" / f"{task_id}.json").unlink(missing_ok=True)
843
+ stamp = _now()
844
+ task["status"], task["leased_at"] = "leased", stamp
845
+ task["lease"] = {"id": secrets.token_hex(4), "at": stamp, "owner": owner or state["host"]}
846
+ if task["kind"] == "system":
847
+ task["covered"] = _done_modules(state)
848
+ labels = _labels(state, task_id)
849
+ handed.append({
850
+ "task": task_id, "kind": task["kind"], "attempt": task["attempts"] + 1,
851
+ "title": _title(state, task), "prompt_file": str(prompt_path),
852
+ "output_file": str(state_dir(run_dir) / "outbox" / f"{task_id}.json"),
853
+ **labels,
854
+ "dispatch_message": (f"Codebase Explorer task {task_id}. Read the instruction file {prompt_path} "
855
+ f"and follow it exactly: it lists the files to read and the JSON file to "
856
+ f"write. Reply only with: DONE {task_id}"),
857
+ })
858
+ title = f" \"{_title(state, task)}\"" if task["module"] is not None else ""
859
+ log_event(run_dir, "claim", f"{task_id}{title} attempt {task['attempts'] + 1} -> {task['lease']['owner']}",
860
+ task=task_id, owner=task["lease"]["owner"])
861
+ summary = _summary(state)
862
+ return {"tasks": handed, **summary}
863
+
864
+
865
+ # ---------------------------------------------------------------- submit
866
+
867
+
868
+ def _checks(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> tuple[Any, Any]:
869
+ """(strict check, normalizer) for a non-detail task; both mutate the value they accept."""
870
+ if task["kind"] == "review":
871
+ return checks.check_review, checks.normalize_review
872
+ _, limit, view, units = _system_inputs(run_dir, state)
873
+ shown = prompts.system_relationship_context(view)
874
+ options = dict(unit_ids=set(units), dependencies=shown["dependencies"],
875
+ has_candidates=bool(shown["candidate_flows"]))
876
+ repaired = False
877
+ def strict(value):
878
+ checks.check_system(value, limit, normalized=repaired, **options)
879
+ def normalize(value):
880
+ nonlocal repaired
881
+ flags = checks.normalize_system(value, limit, **options)
882
+ repaired = True
883
+ return flags
884
+ return strict, normalize
885
+
886
+
887
+ DETAIL_KINDS = {"module", "repair"}
888
+
889
+
890
+ def _partial_path(run_dir: Path, task_id: str) -> Path:
891
+ return state_dir(run_dir) / "partial" / f"{task_id}.json"
892
+
893
+
894
+ def _load_partial(run_dir: Path, task_id: str) -> dict[str, Any]:
895
+ """Units already accepted from earlier answers of one detail task."""
896
+ path = _partial_path(run_dir, task_id)
897
+ if path.is_file():
898
+ return json.loads(path.read_text(encoding="utf-8"))
899
+ if task_id.startswith("tree_"):
900
+ return {"good": {}, "errors": {}}
901
+ return {"units": [], "flags": [], "invalid": [], "notes": "", "decoded": False}
902
+
903
+
904
+ def _detail_table(state: dict[str, Any], task: dict[str, Any]) -> list[dict]:
905
+ return symbols.batch_table(state["modules"][task["module"]]["files"], state["inventory"])
906
+
907
+
908
+ def _gaps(run_dir: Path, state: dict[str, Any], table: list[dict], units: list[dict]) -> list[dict]:
909
+ """Symbols with logic that no unit explains yet (see ``checks.find_gaps``)."""
910
+ wanted = {row["id"] for row in table if row["kind"] in checks.GAP_KINDS}
911
+ tokens = {chunk["id"]: count_text_tokens(chunk.get("text") or "")
912
+ for chunk in _verified_context(run_dir, state).get("chunks") or [] if chunk["id"] in wanted}
913
+ return checks.find_gaps(table, units, tokens)
914
+
915
+
916
+ def _ground_answer(run_dir: Path, state: dict[str, Any], table: list[dict], decoded, final: bool) -> str | None:
917
+ """Reject an answer whose explanations look attached to the wrong symbols (see ``grounding``).
918
+
919
+ One suspect explanation is not acted on: about 3% of correct explanations are suspect, and a
920
+ re-ask sends the whole batch again. Explanations attached to the wrong numbers show as many
921
+ suspects in one answer. On the last answer the suspects are kept and marked ``ungrounded``."""
922
+ chunks = {chunk["id"]: chunk for chunk in _verified_context(run_dir, state).get("chunks") or []}
923
+ by_id = {row["id"]: row for row in table}
924
+ names = grounding.repo_names(symbol["name"] for entry in (state.get("inventory") or {}).values()
925
+ for symbol in entry.get("symbols") or [])
926
+ suspects = {}
927
+ for unit in decoded.units:
928
+ ids = [unit["id"], *(unit.get("members") or [])]
929
+ text = [chunks[sid].get("text") or "" for sid in ids if sid in chunks]
930
+ text += [by_id[sid]["path"] for sid in ids if sid in by_id] + ids
931
+ missing = grounding.ungrounded(unit["explain"], "\n".join(text), names)
932
+ if missing:
933
+ suspects[unit["id"]] = missing
934
+ if len(suspects) < grounding.BATCH_MIN or len(suspects) < grounding.BATCH_SHARE * len(decoded.units):
935
+ return None
936
+ if final:
937
+ for unit in decoded.units:
938
+ if unit["id"] in suspects:
939
+ unit["ungrounded"] = suspects[unit["id"]]
940
+ decoded.flags += [f"ungrounded: {uid}: {', '.join(words)}" for uid, words in suspects.items()]
941
+ return None
942
+ for unit in decoded.units:
943
+ ids = {unit["id"], *(unit.get("members") or [])}
944
+ words = suspects.get(unit["id"])
945
+ decoded.invalid.append({
946
+ "h": [row["n"] for row in table if row["id"] in ids],
947
+ "why": (f"names {', '.join(words)}, not found in the source under these numbers" if words
948
+ else "discarded with the rest of that answer; write it again from the source under these numbers")})
949
+ decoded.units = []
950
+ return (f"ungrounded: {len(suspects)} explanation(s) name identifiers that are not in the source under "
951
+ "their own symbol numbers; they look attached to other numbers. None of that answer was kept. "
952
+ "Check every explanation against the source under its number and answer again.")
953
+
954
+
955
+ def _tree_nodes(state: dict[str, Any], task: dict[str, Any], tree: "treename.Tree") -> dict[str, str]:
956
+ """``{label: node}`` of the nodes a tree task names."""
957
+ if task["kind"] == "tree_name":
958
+ return treename.name_context(state["skeleton"]["batches"][task["batch"]], tree)[0]
959
+ return treename.top_context(tree)
960
+
961
+
962
+ def _tree_retry_note(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> str:
963
+ partial = _load_partial(run_dir, task["id"])
964
+ labels = _tree_nodes(state, task, treename.Tree(state["skeleton"]["nodes"]))
965
+ pending = [label for label, node in labels.items() if node not in partial["good"]]
966
+ return prompts.tree_retry_note(pending, [m for node, rows in partial["errors"].items() for m in rows],
967
+ sorted(r["title"] for r in partial["good"].values()))
968
+
969
+
970
+ def _retry_note(run_dir: Path, state: dict[str, Any], task: dict[str, Any]) -> str | None:
971
+ """The follow-up that a re-ask appends: for a detail task only the gaps, for a tree task only the
972
+ nodes that were rejected."""
973
+ if task["kind"] not in DETAIL_KINDS | set(TREE_KINDS) or not _partial_path(run_dir, task["id"]).is_file():
974
+ return None
975
+ if task["kind"] in TREE_KINDS:
976
+ return _tree_retry_note(run_dir, state, task)
977
+ partial = _load_partial(run_dir, task["id"])
978
+ table = _detail_table(state, task)
979
+ problem = (task["errors"][-1][:300] if task["errors"]
980
+ and task["errors"][-1].startswith(("schema_error", "ungrounded")) else None)
981
+ return prompts.gap_note(_gaps(run_dir, state, table, partial["units"]), partial["invalid"], problem)
982
+
983
+
984
+ def _settle_detail(run_dir: Path, state: dict[str, Any], task: dict[str, Any], value: dict[str, Any] | None,
985
+ error: str | None, final: bool) -> tuple[str | None, dict[str, Any] | None, list[str]]:
986
+ """Decode one detail answer and fold it into the task's accepted units.
987
+
988
+ Returns ``(error, payload, flags)``: ``payload`` is the internal result when the
989
+ batch is complete, or when this was the last answer (remaining gaps are indexed
990
+ without prose and flagged ``unexplained``). Otherwise ``error`` names what is
991
+ still missing and the decoded units stay saved for the re-ask."""
992
+ if value is None and not (error or "").startswith("schema_error"):
993
+ return error, None, [] # a provider-side failure: no answer to decode; saved units wait
994
+ table = _detail_table(state, task)
995
+ partial = _load_partial(run_dir, task["id"])
996
+ decoded = None
997
+ if value is not None:
998
+ try:
999
+ decoded = checks.decode_details(value, table, have=partial["units"],
1000
+ known_ids=_known_unit_ids(run_dir, state, exclude=task["module"]))
1001
+ except ValueError as exc:
1002
+ error = f"schema_error:{exc}"
1003
+ if decoded is not None:
1004
+ grounding_error = _ground_answer(run_dir, state, table, decoded, final)
1005
+ partial["units"] += decoded.units
1006
+ partial["flags"] += decoded.flags
1007
+ partial["invalid"] = decoded.invalid
1008
+ partial["notes"] = decoded.notes or partial["notes"]
1009
+ partial["decoded"] = True
1010
+ if grounding_error:
1011
+ _partial_path(run_dir, task["id"]).parent.mkdir(exist_ok=True)
1012
+ write_json(_partial_path(run_dir, task["id"]), partial)
1013
+ return grounding_error, None, []
1014
+ gaps = _gaps(run_dir, state, table, partial["units"])
1015
+ if decoded is not None and not gaps:
1016
+ # A rejected unit alone never earns a re-ask: if its symbols mattered they are gaps, and
1017
+ # a unit that names no symbol (a stray string in the list) has nothing to recover.
1018
+ error = None
1019
+ partial["flags"] += [f"invalid unit h={row['h']}: {row['why']}" for row in decoded.invalid]
1020
+ elif not final or not partial["decoded"]:
1021
+ if decoded is not None:
1022
+ _partial_path(run_dir, task["id"]).parent.mkdir(exist_ok=True)
1023
+ write_json(_partial_path(run_dir, task["id"]), partial)
1024
+ error = (f"incomplete: {len(gaps)} symbol(s) with logic still without a unit "
1025
+ f"{[row['n'] for row in gaps][:20]}; {len(decoded.invalid)} unit(s) rejected")
1026
+ return error, None, []
1027
+ else:
1028
+ error = None
1029
+ partial["flags"] += [f"invalid unit h={row['h']}: {row['why']}" for row in partial["invalid"]]
1030
+ partial["flags"] += [f"unexplained: {row['id']}" for row in gaps]
1031
+ _partial_path(run_dir, task["id"]).unlink(missing_ok=True)
1032
+ return None, checks.assemble_details(table, partial["units"], notes=partial["notes"]), partial["flags"]
1033
+
1034
+
1035
+ def _settle_tree(run_dir: Path, state: dict[str, Any], task: dict[str, Any], value: dict[str, Any] | None,
1036
+ error: str | None, final: bool) -> tuple[str | None, dict[str, Any] | None, list[str]]:
1037
+ """Decode one tree_name or tree_top answer node by node (see ``treename``).
1038
+
1039
+ Same contract as ``_settle_detail``: the good nodes are kept, a re-ask names only the rest, and
1040
+ after the last answer every node still unnamed gets a program-made title, so the task completes."""
1041
+ if value is None and not (error or "").startswith("schema_error"):
1042
+ return error, None, [] # a provider-side failure: no answer to decode; saved nodes wait
1043
+ records, _ = _aggregation_inputs(run_dir, state)
1044
+ tree = treename.Tree(state["skeleton"]["nodes"])
1045
+ labels = _tree_nodes(state, task, tree)
1046
+ partial = _load_partial(run_dir, task["id"])
1047
+ pending = set(labels.values()) - set(partial["good"])
1048
+ taken = {treename.normalize_title(r["title"]) for r in partial["good"].values()}
1049
+ problems: dict[str, list[str]] = {}
1050
+ if value is not None:
1051
+ try:
1052
+ if task["kind"] == "tree_name":
1053
+ good, problems = treename.decode_names(value, state["skeleton"]["batches"][task["batch"]], tree,
1054
+ pending, taken)
1055
+ else:
1056
+ good, problems = treename.decode_top(value, tree, _tree_names_for_top(run_dir, state, tree, records),
1057
+ pending, taken)
1058
+ partial["good"].update(good)
1059
+ except ValueError as exc:
1060
+ error = f"schema_error:{exc}"
1061
+ pending -= set(partial["good"])
1062
+ if not pending:
1063
+ error, flags = None, []
1064
+ elif not final:
1065
+ if partial["good"]:
1066
+ _partial_path(run_dir, task["id"]).parent.mkdir(exist_ok=True)
1067
+ write_json(_partial_path(run_dir, task["id"]), {**partial, "errors": problems})
1068
+ shown = "; ".join(m for rows in problems.values() for m in rows)[:300] or (error or "")
1069
+ return f"incomplete: {len(pending)} of {len(labels)} node(s) not named: {shown}", None, []
1070
+ else:
1071
+ error, flags = None, []
1072
+ for label, node in labels.items():
1073
+ if node in pending:
1074
+ partial["good"][node] = treename.program_record(records, tree.members(node))
1075
+ why = "; ".join(problems.get(node, [])) or "no usable answer"
1076
+ flags.append(f"program-made title for {label}: {why}"[:300])
1077
+ _partial_path(run_dir, task["id"]).unlink(missing_ok=True)
1078
+ return None, {"names": partial["good"]}, flags
1079
+
1080
+
1081
+ def _usage_entry(usage: dict[str, Any] | None, attempt: int) -> dict[str, Any] | None:
1082
+ if not usage or not any(value is not None for value in usage.values()):
1083
+ return None
1084
+ return {"attempt": attempt, "basis": "reported", **{k: v for k, v in usage.items() if v is not None}}
1085
+
1086
+
1087
+ def _apply_submit(run_dir: Path, state: dict[str, Any], task: dict[str, Any], result: Path,
1088
+ usage: dict[str, Any] | None, *, error: str | None = None,
1089
+ raw_text: str | None = None) -> dict[str, Any]:
1090
+ task["attempts"] += 1
1091
+ attempt = task["attempts"]
1092
+ limit = _attempt_limit(task)
1093
+ round_attempt = attempt - task.get("attempt_base", 0)
1094
+ final = round_attempt >= limit
1095
+ entry = _usage_entry(usage, attempt)
1096
+ if entry:
1097
+ task["usage"].append(entry)
1098
+ stamp = _now()
1099
+ task["submitted_at"] = stamp
1100
+ task["seconds"] = round(epoch(stamp) - epoch(task["leased_at"]), 1) if task.get("leased_at") else None
1101
+ raw = raw_text if error is not None else (result.read_text(encoding="utf-8") if result.is_file() else None)
1102
+ if error is None and raw is None:
1103
+ error = f"no result file at {result}"
1104
+ archive = state_dir(run_dir) / "raw"
1105
+ if raw is not None:
1106
+ (archive / f"{task['id']}.attempt-{attempt}.txt").write_text(raw, encoding="utf-8")
1107
+ value: dict[str, Any] | None = None
1108
+ flags: list[str] = []
1109
+ detail = task["kind"] in DETAIL_KINDS
1110
+ naming = task["kind"] in TREE_KINDS
1111
+ if not (detail or naming):
1112
+ strict, normalize = _checks(run_dir, state, task)
1113
+ if error is None:
1114
+ try:
1115
+ value = checks.json_object(raw or "")
1116
+ if not (detail or naming):
1117
+ strict(value)
1118
+ except (ValueError, TypeError) as exc:
1119
+ error, value = f"schema_error:{exc}", None
1120
+ if detail:
1121
+ error, value, flags = _settle_detail(run_dir, state, task, value, error, final)
1122
+ elif naming:
1123
+ error, value, flags = _settle_tree(run_dir, state, task, value, error, final)
1124
+ elif error is not None and final:
1125
+ # Answers without delivered source or with provider truncation must
1126
+ # never be resurrected by the final-attempt normalizer.
1127
+ undelivered = set(task.get("undelivered_attempts") or []) | set(task.get("unsafe_attempts") or [])
1128
+ if error.startswith(("source_not_delivered", "output_limit")):
1129
+ undelivered.add(attempt)
1130
+ for number in range(attempt, task.get("attempt_base", 0), -1):
1131
+ if number in undelivered:
1132
+ continue
1133
+ candidate = archive / f"{task['id']}.attempt-{number}.txt"
1134
+ if not candidate.is_file():
1135
+ continue
1136
+ try:
1137
+ parsed = checks.json_object(candidate.read_text(encoding="utf-8"))
1138
+ flags = normalize(parsed)
1139
+ strict(parsed)
1140
+ except (ValueError, TypeError):
1141
+ continue
1142
+ value = parsed
1143
+ task["normalized_from"] = error
1144
+ error = None
1145
+ break
1146
+ task["lease"] = None
1147
+ label = f"{task['id']}" + (f" \"{_title(state, task)}\"" if task["module"] is not None else "")
1148
+ timing = f" in {task['seconds']}s" if task["seconds"] is not None else ""
1149
+ if error is not None:
1150
+ task["errors"].append(error)
1151
+ if error.startswith("source_not_delivered"):
1152
+ task.setdefault("undelivered_attempts", []).append(attempt)
1153
+ if error.startswith("output_limit"):
1154
+ task.setdefault("unsafe_attempts", []).append(attempt)
1155
+ result.unlink(missing_ok=True)
1156
+ task["status"] = "failed" if final else "pending"
1157
+ if task["status"] == "failed":
1158
+ log_event(run_dir, "fail", f"{label} after {attempt} total attempts "
1159
+ f"({round_attempt}/{limit} this round){timing}: {error[:300]}; "
1160
+ "pending until the run is resumed", task=task["id"], error=error[:300],
1161
+ attempt=attempt, round_attempt=round_attempt, attempt_limit=limit)
1162
+ else:
1163
+ log_event(run_dir, "reject", f"{label} attempt {attempt} ({round_attempt}/{limit} this round)"
1164
+ f"{timing}: {error[:300]}; re-asking with this error",
1165
+ task=task["id"], attempt=attempt, error=error[:300],
1166
+ round_attempt=round_attempt, attempt_limit=limit)
1167
+ _maybe_render(run_dir, state, refresh=False)
1168
+ return {"accepted": False, "task": task["id"], "error": error[:1000],
1169
+ "attempts": attempt, "task_status": task["status"]}
1170
+ assert value is not None
1171
+ write_json(state_dir(run_dir) / "accepted" / f"{task['id']}.json", value)
1172
+ task["status"], task["flags"] = "done", flags
1173
+ task["output"] = f"host/accepted/{task['id']}.json"
1174
+ if flags:
1175
+ log_event(run_dir, "normalize", f"{label} attempt {attempt}: accepted after normalization: "
1176
+ + "; ".join(flags)[:600], task=task["id"], flags=flags)
1177
+ log_event(run_dir, "accept", f"{label} attempt {attempt}{timing} -> {task['output']}", task=task["id"],
1178
+ attempt=attempt)
1179
+ if task["kind"] == "review" and value["decision"] == "needs_repair" and value["issues"]:
1180
+ repair_id = f"repair_{task['module'] + 1}"
1181
+ if repair_id not in state["tasks"]:
1182
+ state["tasks"][repair_id] = _task(repair_id, "repair", task["module"], issues=value["issues"])
1183
+ log_event(run_dir, "queue", f"{repair_id} for {len(value['issues'])} review finding(s)", task=repair_id)
1184
+ if state["review"] == "sample" and not state.get("review_escalated"):
1185
+ _escalate_review(run_dir, state, task, len(value["issues"]))
1186
+ elif task["kind"] in {"module", "repair"}:
1187
+ # The skeleton and the names describe a fixed detail snapshot: a repair or a late module
1188
+ # changes the unit set, so the tree is made again.
1189
+ if state.get("skeleton"):
1190
+ _invalidate_tree(run_dir, state)
1191
+ system = state["tasks"]["system"]
1192
+ if system["status"] == "done" and (task["kind"] == "repair"
1193
+ or task["module"] not in system.get("covered", [])):
1194
+ # The overview was written without this module's current output; ask
1195
+ # for a fresh one.
1196
+ system["status"], system["attempt_base"] = "pending", system["attempts"]
1197
+ log_event(run_dir, "queue", "system again so the overview covers the newly finished module", task="system")
1198
+ _maybe_render(run_dir, state, force=task["kind"] == "system")
1199
+ return {"accepted": True, "task": task["id"], "attempts": attempt, "task_status": "done",
1200
+ "normalized": bool(flags), "flags": flags}
1201
+
1202
+
1203
+ def submit(run_dir: Path, task_id: str, *, result: Path | None = None, usage: dict[str, Any] | None = None,
1204
+ error: str | None = None, raw_text: str | None = None) -> dict[str, Any]:
1205
+ with locked_state(run_dir) as state:
1206
+ # Evidence is only consumed against the frozen snapshot.
1207
+ _verified_context(run_dir, state)
1208
+ task = state["tasks"].get(task_id)
1209
+ if task is None:
1210
+ raise HostRunError(f"unknown task {task_id}")
1211
+ if task["status"] == "done":
1212
+ return {"accepted": True, "task": task_id, "already": True, **_summary(state)}
1213
+ if task["status"] == "failed":
1214
+ raise HostRunError(f"{task_id} failed after {task['attempts']} attempts; use `cbe host retry`")
1215
+ outcome = _apply_submit(run_dir, state, task, result or state_dir(run_dir) / "outbox" / f"{task_id}.json",
1216
+ usage, error=error, raw_text=raw_text)
1217
+ return {**outcome, **_summary(state)}
1218
+
1219
+
1220
+ def note_usage_unknown(run_dir: Path, task_id: str, attempt: int) -> None:
1221
+ """Record a completed script-driver call whose host reported no usage at all.
1222
+
1223
+ Per-call source delivery could not be verified for it. The call is not
1224
+ failed on this, but the gap must be visible in STATUS.md."""
1225
+ with locked_state(run_dir) as state:
1226
+ state.setdefault("usage_unknown", []).append({"task": task_id, "attempt": attempt})
1227
+ log_event(run_dir, "usage", f"{task_id} attempt {attempt} reported no usage; "
1228
+ "per-call source delivery not verified", task=task_id, attempt=attempt)
1229
+
1230
+
1231
+ # ---------------------------------------------------------------- status / resume
1232
+
1233
+
1234
+ def _fallback_fields(state: dict[str, Any]) -> dict[str, Any]:
1235
+ """``<code>`` and ``<code>_count`` for every source fallback the plan recorded."""
1236
+ return {key: value for code in FALLBACK_CODES if (rows := state.get(code, []))
1237
+ for key, value in ((code + "_count", len(rows)), (code, rows))}
1238
+
1239
+
1240
+ def _summary(state: dict[str, Any]) -> dict[str, Any]:
1241
+ tasks = list(state["tasks"].values())
1242
+ counts = dict(Counter(task["status"] for task in tasks))
1243
+ failed = [{"task": task["id"], "error": task["errors"][-1][:300]} for task in tasks
1244
+ if task["status"] == "failed"]
1245
+ leased = [task["id"] for task in tasks if task["status"] == "leased"]
1246
+ ready = _ready(state)
1247
+ run = state["run_dir"]
1248
+ if state["status"] == "complete":
1249
+ action = f"done; open {run}/docs/INDEX.md, then run `cbe host cost --run-dir {run}`"
1250
+ elif state["status"] == "partial" and state.get("partial_reason") == "source_not_embedded":
1251
+ action = (f"partial: source_not_embedded ({state.get('partial_detail', '')}); "
1252
+ "source changed or could not be embedded from the frozen plan. "
1253
+ "Create a new plan for the current source before generating documentation.")
1254
+ elif state["status"] == "partial" and state.get("partial_reason"):
1255
+ affected = ", ".join(row["task"] for row in failed) or state.get("partial_detail", "")
1256
+ again = (f"`cbe generate {state['repo']} --resume --run-dir {run}`" if state.get("driver") == "script"
1257
+ else f"`cbe host resume --run-dir {run}` and continue with `cbe host next`")
1258
+ action = (f"partial: {state['partial_reason']} ({state.get('partial_detail', '')}); "
1259
+ f"affected tasks: {affected}. Rejected answers were not published. Fix the cause, then {again}")
1260
+ elif state["status"] == "partial" and state.get("verify") and not state["verify"]["ok"] and not failed:
1261
+ names = ", ".join(row["check"] for row in state["verify"]["checks"] if not row["ok"])
1262
+ action = (f"partial: deterministic verification failed ({names}); inspect `cbe host verify --run-dir {run}`, "
1263
+ "fix the cause and verify again")
1264
+ elif state["status"] == "partial":
1265
+ again = (f"`cbe generate {state['repo']} --resume --run-dir {run}`" if state.get("driver") == "script"
1266
+ else f"`cbe host resume --run-dir {run}` and continue with `cbe host next`")
1267
+ action = (f"partial: docs are rendered and {len(failed)} task(s) are pending; fix the cause, then {again}")
1268
+ elif leased and not ready:
1269
+ action = "wait for the dispatched subagents, then submit each task"
1270
+ else:
1271
+ action = f"cbe host next --run-dir {run}"
1272
+ unknown = [f"{row['task']}#attempt-{row['attempt']}" for row in state.get("usage_unknown") or []]
1273
+ return {"status": state["status"], "counts": counts, "leased": leased, "failed": failed,
1274
+ "ready": len(ready), "next_action": action, "usage_unknown": unknown,
1275
+ **_fallback_fields(state)}
1276
+
1277
+
1278
+ def status(run_dir: Path) -> dict[str, Any]:
1279
+ with locked_state(run_dir) as state:
1280
+ rows = [{"task": task["id"], "title": _title(state, task), "status": task["status"],
1281
+ "attempts": task["attempts"], "seconds": task["seconds"], "output": task["output"],
1282
+ "flags": len(task["flags"])} for task in state["tasks"].values()]
1283
+ return {"run_dir": state["run_dir"], "run_tag": state["run_tag"], "review": state["review"],
1284
+ "jobs": state["jobs"], "modules": len(state["modules"]), "tasks": rows,
1285
+ "progress_log": str(run_dir / "progress.log"), **_summary(state)}
1286
+
1287
+
1288
+ def resume(run_dir: Path, *, owner: str | None = None, retry_failed: bool = True,
1289
+ graph_path: Path | None = None) -> dict[str, Any]:
1290
+ """Continue a run from its records: adopt finished outbox files, release lost
1291
+ leases, and (by default) give failed tasks fresh attempts.
1292
+
1293
+ A ``graph_path`` passed on resume must be the same graph the plan froze; a
1294
+ different graph is rejected instead of silently ignored."""
1295
+ with locked_state(run_dir) as state:
1296
+ _verified_context(run_dir, state)
1297
+ state.setdefault("runtime_environments", []).append(_runtime_environment())
1298
+ _auto_complete_empty_modules(run_dir, state)
1299
+ if graph_path is not None:
1300
+ frozen = state.get("context") or {}
1301
+ if not frozen.get("graph_present"):
1302
+ raise HostRunError("this run was planned without a graph; --graph cannot be added on resume")
1303
+ try:
1304
+ provided = json.loads(Path(graph_path).read_text(encoding="utf-8"))
1305
+ except (OSError, json.JSONDecodeError) as exc:
1306
+ raise HostRunError(f"cannot read the --graph passed on resume: {exc}") from exc
1307
+ if context_digest(provided) != frozen.get("graph_digest"):
1308
+ raise HostRunError("the --graph passed on resume differs from the frozen graph; "
1309
+ "start a new plan to change the graph")
1310
+ if owner:
1311
+ state["host"] = owner
1312
+ changed = _drift(state)
1313
+ submitted, released, retried = [], [], []
1314
+ for task in list(state["tasks"].values()):
1315
+ if task["status"] != "leased":
1316
+ continue
1317
+ outbox = state_dir(run_dir) / "outbox" / f"{task['id']}.json"
1318
+ if outbox.is_file():
1319
+ submitted.append(_apply_submit(run_dir, state, task, outbox, None))
1320
+ else:
1321
+ task["status"], task["lease"] = "pending", None
1322
+ released.append(task["id"])
1323
+ if retry_failed and not changed:
1324
+ for task in state["tasks"].values():
1325
+ if task["status"] == "failed":
1326
+ task["status"], task["attempt_base"] = "pending", task["attempts"]
1327
+ retried.append(task["id"])
1328
+ if (released or retried) and state["status"] == "partial":
1329
+ state["status"] = "running"
1330
+ limits = {tid: _attempt_limit(state["tasks"][tid]) for tid in retried}
1331
+ log_event(run_dir, "resume", f"by {owner or state['host']}: adopted {[row['task'] for row in submitted]}, "
1332
+ f"released {released}, retrying {retried}"
1333
+ + (f"; fresh attempts {limits}" if limits else "")
1334
+ + (f"; {len(changed)} source file(s) changed" if changed else ""),
1335
+ submitted=[row["task"] for row in submitted], released=released, retried=retried,
1336
+ **({"attempt_limits": limits} if limits else {}))
1337
+ _maybe_render(run_dir, state, refresh=False)
1338
+ return {"submitted": submitted, "released": released, "retried": retried, "changed_sources": changed[:50],
1339
+ **({"warning": "sources changed since plan; start a new plan"} if changed else {}),
1340
+ **_summary(state)}
1341
+
1342
+
1343
+ def retry(run_dir: Path, task_id: str) -> dict[str, Any]:
1344
+ with locked_state(run_dir) as state:
1345
+ chosen = [task for task in state["tasks"].values()
1346
+ if task["status"] == "failed" and task_id in ("all", task["id"])]
1347
+ if not chosen:
1348
+ raise HostRunError(f"{task_id} is not a failed task")
1349
+ for task in chosen:
1350
+ # Keep the cumulative count: raw archives and cost rows stay per attempt.
1351
+ task["status"], task["attempt_base"] = "pending", task["attempts"]
1352
+ log_event(run_dir, "retry", f"{task['id']} gets {_attempt_limit(task)} fresh attempts",
1353
+ task=task["id"], attempt_limit=_attempt_limit(task))
1354
+ if state["status"] == "partial":
1355
+ state["status"] = "running"
1356
+ return _summary(state)
1357
+
1358
+
1359
+ def release(run_dir: Path, task_id: str | None = None) -> dict[str, Any]:
1360
+ with locked_state(run_dir) as state:
1361
+ released = []
1362
+ for task in state["tasks"].values():
1363
+ if task["status"] == "leased" and task_id in (None, task["id"]):
1364
+ task["status"], task["lease"] = "pending", None
1365
+ released.append(task["id"])
1366
+ if released:
1367
+ log_event(run_dir, "release", f"returned {released} to the queue", tasks=released)
1368
+ return {"released": released, **_summary(state)}
1369
+
1370
+
1371
+ # ---------------------------------------------------------------- render / verify
1372
+
1373
+
1374
+ def _maybe_render(run_dir: Path, state: dict[str, Any], *, force: bool = False, refresh: bool = True) -> None:
1375
+ """Render when the overview arrives, when nothing more can run, or to refresh rendered docs."""
1376
+ terminal = all(_settled(state, task) for task in state["tasks"].values())
1377
+ if force or terminal or (refresh and state.get("rendered")):
1378
+ _render(run_dir, state)
1379
+
1380
+
1381
+ def _mechanical_semantic_overview(state: dict[str, Any], tree: dict[str, Any]) -> dict[str, str]:
1382
+ nodes = tree.get("nodes") or []
1383
+ return {"overview": (f"This map covers {len(state['files'])} source files in {len(nodes)} semantic "
1384
+ "nodes. The model-written overview is not available yet, so this summary is "
1385
+ "generated mechanically from the responsibility tree.")}
1386
+
1387
+
1388
+ def _escalate_review(run_dir: Path, state: dict[str, Any], task: dict[str, Any], issues: int) -> None:
1389
+ """A sampled batch with findings means this run's writing is not reliable: review every batch.
1390
+
1391
+ Acceptance sampling's tightened inspection: a clean sample costs nothing extra; a sample with
1392
+ defects pays for full review. Batches without units (known empty) have nothing to review."""
1393
+ added = []
1394
+ for module in state["modules"]:
1395
+ review_id = f"review_{module['number'] + 1}"
1396
+ if review_id in state["tasks"] or "known_empty" in _module_task(state, module["number"])["flags"]:
1397
+ continue
1398
+ state["tasks"][review_id] = _task(review_id, "review", module["number"])
1399
+ added.append(review_id)
1400
+ state["review_escalated"] = {"from": task["id"], "issues": issues, "added": added}
1401
+ log_event(run_dir, "queue", f"{len(added)} more review(s): the sampled {task['id']} reported {issues} "
1402
+ "issue(s), so every batch is reviewed", task=task["id"], added=added)
1403
+
1404
+
1405
+ def _review_note(run_dir: Path, state: dict[str, Any]) -> str:
1406
+ """One RUN.md line: which batches a second model read, what it found, what was not read."""
1407
+ batches = [m for m in state["modules"] if "known_empty" not in _module_task(state, m["number"])["flags"]]
1408
+ review = _semantic_review_summary(run_dir, state)
1409
+ done, found = review["reviewed"], sum(len(row["issues"]) for row in review["reviewed"])
1410
+ if state["review"] == "none":
1411
+ return "Review: none; no batch was read by a second model."
1412
+ escalated = state.get("review_escalated")
1413
+ if escalated:
1414
+ return (f"Review: the sampled batch ({escalated['from']}) reported {escalated['issues']} issue(s), "
1415
+ f"so every batch was reviewed: {len(done)} of {len(batches)} reviewed, {found} issue(s) in total"
1416
+ + (f", {len(review['pending'])} still pending." if review["pending"] else "."))
1417
+ if state["review"] == "sample":
1418
+ if not done:
1419
+ return "Review: one sampled batch, not reviewed yet."
1420
+ row = done[0]
1421
+ units = len((_module_output(run_dir, state, row["module"]) or {}).get("units") or [])
1422
+ return (f"Review: sampled batch module_{row['module'] + 1} ({units} units), {len(row['issues'])} issue(s); "
1423
+ f"the other {len(batches) - 1} batch(es) were not reviewed.")
1424
+ return (f"Review: {len(done)} of {len(batches)} batches reviewed, {found} issue(s) in total"
1425
+ + (f", {len(review['pending'])} still pending." if review["pending"] else "."))
1426
+
1427
+
1428
+ def _semantic_review_summary(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]:
1429
+ """A separate record of the independent semantic review, not a validity score.
1430
+
1431
+ ``deterministic_checks_ok`` reports structure and coverage. A review clears
1432
+ only the module whose source a fresh reviewer read; it never proves the
1433
+ whole map, and ``needs_repair`` findings are listed, not hidden."""
1434
+ reviewed, pending = [], []
1435
+ for module in state["modules"]:
1436
+ task_id = f"review_{module['number'] + 1}"
1437
+ task = state["tasks"].get(task_id)
1438
+ if task is None:
1439
+ continue
1440
+ if task["status"] == "done":
1441
+ output = _accepted(run_dir, task_id) or {}
1442
+ repair_id = f"repair_{module['number'] + 1}"
1443
+ repair = state["tasks"].get(repair_id)
1444
+ reviewed.append({"module": module["number"], "decision": output.get("decision"),
1445
+ "issues": output.get("issues") or [],
1446
+ "repair": repair["status"] if repair else None})
1447
+ else:
1448
+ pending.append(task_id)
1449
+ outcomes: dict[str, int] = {}
1450
+ for row in reviewed:
1451
+ outcomes[row["decision"]] = outcomes.get(str(row["decision"]), 0) + 1
1452
+ return {"mode": state.get("review"), "reviewed": reviewed, "outcomes": outcomes,
1453
+ "pending": pending, "escalated": state.get("review_escalated"),
1454
+ "note": "an independent reviewer read the source for each reviewed batch; "
1455
+ "this is not a whole-map proof"}
1456
+
1457
+
1458
+ def _unexplained(state: dict[str, Any]) -> list[dict[str, str]]:
1459
+ """Symbols that kept no prose because no answer explained them (flag ``unexplained: <id>``)."""
1460
+ return [{"task": task["id"], "symbol": flag.removeprefix("unexplained: ")}
1461
+ for task in sorted(state["tasks"].values(), key=lambda row: row["id"])
1462
+ for flag in task.get("flags") or [] if flag.startswith("unexplained: ")]
1463
+
1464
+
1465
+ def _basis(run_dir: Path, state: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
1466
+ """The recorded inputs behind this rendering; never infer an unrecorded model."""
1467
+ records = [record for task in state["tasks"].values()
1468
+ for record in [*(task.get("usage") or []), *(task.get("exact_usage") or [])]]
1469
+ models = {part.strip() for record in records for part in str(record.get("model") or "").split(",")
1470
+ if part.strip()}
1471
+ for path in (run_dir / "raw").glob("*.api.json"): # written by `cbe generate` for every call
1472
+ try:
1473
+ actual = json.loads(path.read_text(encoding="utf-8")).get("actual_model")
1474
+ except (OSError, ValueError):
1475
+ continue
1476
+ if isinstance(actual, str) and actual:
1477
+ models.add(actual)
1478
+ source_rows = [{"path": row["path"], "digest": row["digest"]} for row in state["files"]]
1479
+ source_digest = hashlib.sha256(json.dumps(
1480
+ source_rows, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
1481
+ graph = context.get("graph")
1482
+ updates = state.get("graph_updates") or []
1483
+ graph_basis = None if graph is None else {
1484
+ "digest": state["context"]["graph_digest"], "backend": graph.get("backend"),
1485
+ "edge_counts": dict(sorted(Counter(edge.get("kind") for edge in graph.get("edges") or []).items())),
1486
+ "updated": bool(updates), "updated_at": updates[-1]["at"] if updates else None}
1487
+ return {"cbe_version": state["cbe_version"], "models": sorted(models),
1488
+ "source": {"files": len(source_rows), "sha256": source_digest}, "graph": graph_basis}
1489
+
1490
+
1491
+ def _render(run_dir: Path, state: dict[str, Any]) -> None:
1492
+ try:
1493
+ context = _verified_context(run_dir, state)
1494
+ _auto_complete_empty_modules(run_dir, state)
1495
+ tree = _semantic_tree(run_dir, state)
1496
+ except (HostRunError, symbols.SemanticError) as exc:
1497
+ state["render_error"] = str(exc)
1498
+ log_event(run_dir, "render", f"failed: {exc}", error=str(exc))
1499
+ return
1500
+ units, dispositions = _semantic_units(run_dir, state)
1501
+ pending = [f"module_{row['number'] + 1}" for row in state["modules"]
1502
+ if _module_output(run_dir, state, row["number"]) is None]
1503
+ index_notes: list[str] = []
1504
+ open_tree = [tid for tid, task in state["tasks"].items() if task["kind"] in TREE_KINDS and task["status"] != "done"]
1505
+ if open_tree:
1506
+ index_notes.append("Module names come from the program (frequent words and the main directory) "
1507
+ "where these tasks are not finished: " + ", ".join(open_tree)
1508
+ + "; resume the run to finish them.")
1509
+ elif units and not state.get("skeleton"):
1510
+ index_notes.append("The module tree is a program grouping with program-made names: it is not frozen "
1511
+ "yet because reading is not finished.")
1512
+ made = sum(1 for node in tree.get("nodes") or [] if node.get("note"))
1513
+ if made:
1514
+ index_notes.append(f"{made} module title(s) are program-made (frequent words and main directory) "
1515
+ "because no usable model name was accepted for them.")
1516
+ system_task = state["tasks"]["system"]
1517
+ system = _accepted(run_dir, "system")
1518
+ if system is None:
1519
+ index_notes.append("The overview is mechanical because the `system` task is "
1520
+ + ("failed; resume the run to retry it." if system_task["status"] == "failed"
1521
+ else "not finished yet."))
1522
+ else:
1523
+ if system_task["status"] != "done":
1524
+ index_notes.append("The overview was written before some modules finished; it will update "
1525
+ "when the resumed `system` task completes.")
1526
+ index_notes += [f"CBE normalized the overview after a second invalid answer: {flag}"
1527
+ for flag in system_task["flags"]]
1528
+ index_notes.append(_review_note(run_dir, state))
1529
+ if pending:
1530
+ index_notes.append(f"{len(pending)} module page(s) are pending: {', '.join(pending)}.")
1531
+ known_empty = sorted(path for path, entry in (state.get("inventory") or {}).items()
1532
+ if entry.get("known_empty"))
1533
+ if known_empty:
1534
+ index_notes.append("Known-empty files (no source text, no inventory id, no unit, no model "
1535
+ "call): " + ", ".join(known_empty) + ".")
1536
+ normalized = sorted(task["id"] for task in state["tasks"].values()
1537
+ if task["flags"] and "known_empty" not in task["flags"])
1538
+ if normalized:
1539
+ index_notes.append("Accepted with repairs or gaps (see each task's flags in host/state.json): "
1540
+ + ", ".join(normalized) + ".")
1541
+ unexplained_rows = _unexplained(state)
1542
+ unexplained: dict[str, int] = {}
1543
+ for row in unexplained_rows:
1544
+ task = state["tasks"][row["task"]]
1545
+ repair = (state["tasks"].get(f"repair_{task['module'] + 1}") if task["kind"] == "module" else None)
1546
+ if task["status"] == "done" and not (repair and repair["status"] == "done"):
1547
+ unexplained[row["task"]] = unexplained.get(row["task"], 0) + 1
1548
+ for tid, count in sorted(unexplained.items()):
1549
+ index_notes.append(f"{tid}: {count} function/method/class symbol(s) were left without prose "
1550
+ "because no model answer explained them.")
1551
+ basis = _basis(run_dir, state, context)
1552
+ try:
1553
+ meta = docs.render(run_dir=run_dir, source_root=Path(state["repo"]),
1554
+ inventory=state.get("inventory") or {}, units=units,
1555
+ dispositions=dispositions, tree=tree, index_notes=index_notes,
1556
+ system=system or _mechanical_semantic_overview(state, tree),
1557
+ graph=context.get("graph"), context=context, basis=basis,
1558
+ source_tokens=sum(row["tokens"] for row in state["files"]),
1559
+ languages={row["path"]: row.get("language") or "unknown" for row in context["files"]})
1560
+ except symbols.SemanticError as exc:
1561
+ state["render_error"] = str(exc)
1562
+ log_event(run_dir, "render", f"failed: {exc}", error=str(exc))
1563
+ return
1564
+ state.pop("render_error", None)
1565
+ state["published"] = {"units": meta["units_rendered"], "nodes": meta["nodes"]}
1566
+ reason = state.get("partial_reason")
1567
+ complete = all(task["status"] == "done" for task in state["tasks"].values()) and not reason
1568
+ state["status"], state["rendered"] = ("complete" if complete else "partial"), True
1569
+ if not complete and not all(_settled(state, task) for task in state["tasks"].values()):
1570
+ state["status"] = "running"
1571
+ published_pages = meta["nodes"] + 1
1572
+ summary = {"status": state["status"], "driver": state.get("driver", "host"), "host": state["host"],
1573
+ "source_files": len(state["files"]),
1574
+ "source_tokens": sum(row["tokens"] for row in state["files"]),
1575
+ "modules": len(state["modules"]), "units": meta["units_rendered"],
1576
+ "semantic_nodes": meta["nodes"], "published_pages": published_pages,
1577
+ "pending": pending + open_tree + (["system"] if system_task["status"] != "done" else []),
1578
+ "normalized": normalized,
1579
+ "unexplained_count": len(unexplained_rows),
1580
+ "unexplained": unexplained_rows,
1581
+ "known_empty": sorted(task["id"] for task in state["tasks"].values()
1582
+ if "known_empty" in task["flags"]),
1583
+ "wall_seconds": _wall_seconds(run_dir, state), "basis": basis,
1584
+ "run_dir": str(run_dir), "index": str(run_dir / "docs" / "INDEX.md"),
1585
+ "subagent_tasks": sum(task["attempts"] for task in state["tasks"].values())}
1586
+ ungrounded_count = sum(bool(unit.get("ungrounded")) for unit in units.values())
1587
+ if ungrounded_count:
1588
+ summary["ungrounded_count"] = ungrounded_count
1589
+ summary.update(_fallback_fields(state))
1590
+ if reason:
1591
+ summary["partial_reason"], summary["partial_detail"] = reason, state.get("partial_detail", "")
1592
+ state["verify"] = verify_state(run_dir, state)
1593
+ if not state["verify"]["ok"] and state["status"] == "complete":
1594
+ state["status"] = "partial"
1595
+ summary["status"] = state["status"]
1596
+ summary["deterministic_checks_ok"] = state["verify"]["ok"]
1597
+ summary["tree"] = {"leaves": sum(1 for node in tree["nodes"] if node.get("unit_ids")),
1598
+ "nodes": len(tree["nodes"]), "program_made_titles": made,
1599
+ "naming_tasks": sum(task["kind"] in TREE_KINDS for task in state["tasks"].values()),
1600
+ "index": meta["index"]}
1601
+ summary["node_dependencies"] = meta["relationship_counts"]
1602
+ summary["within_containment"] = meta["within_containment_count"]
1603
+ summary["key_flows"] = meta["key_flows"]
1604
+ summary["candidate_flows"] = meta["candidate_flows"]
1605
+ summary["layers"] = catalog_query.layer_metrics(run_dir)
1606
+ summary["semantic_review"] = _semantic_review_summary(run_dir, state)
1607
+ summary["truth"] = ("deterministic structure/coverage checks and the separate semantic review are "
1608
+ "different states; neither one proves prose truth")
1609
+ write_json(run_dir / "summary.json", summary)
1610
+ log_event(run_dir, "render", f"{published_pages} pages, status {state['status']}"
1611
+ + (f", pending {summary['pending']}" if summary["pending"] else "")
1612
+ + f", verify {'ok' if state['verify']['ok'] else 'has failures'} -> docs/INDEX.md",
1613
+ pages=published_pages, status=state["status"], verify_ok=state["verify"]["ok"])
1614
+
1615
+
1616
+ def verify_state(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]:
1617
+ checks: list[dict[str, Any]] = []
1618
+
1619
+ def check(name: str, ok: bool, detail: Any = None) -> None:
1620
+ checks.append({"check": name, "ok": bool(ok), **({"detail": detail} if detail else {})})
1621
+
1622
+ unfinished = [task["id"] for task in state["tasks"].values() if task["status"] != "done"]
1623
+ check("all_tasks_done", not unfinished, unfinished[:20])
1624
+ try:
1625
+ _verified_context(run_dir, state)
1626
+ check("frozen_context_intact", True)
1627
+ except HostRunError as exc:
1628
+ check("frozen_context_intact", False, str(exc)[:300])
1629
+ changed = _drift(state)
1630
+ check("sources_unchanged", not changed, changed[:20])
1631
+ planned = [row["path"] for row in state["files"]]
1632
+ assigned = [path for module in state["modules"] for path in module["files"]]
1633
+ duplicates = sorted({path for path in assigned if assigned.count(path) > 1})
1634
+ check("each_file_in_exactly_one_module", sorted(assigned) == sorted(planned) and not duplicates,
1635
+ {"planned": len(planned), "assigned": len(assigned), "duplicates": duplicates[:20]})
1636
+ try:
1637
+ current = {item.path for item in batching.scan(Path(state["repo"]))}
1638
+ except batching.BatchingError as exc:
1639
+ current = set()
1640
+ check("rescan", False, str(exc))
1641
+ unplanned = sorted(current - set(planned))
1642
+ check("no_unplanned_source_files", not unplanned, unplanned[:20])
1643
+ if not (run_dir / "docs" / "INDEX.md").is_file() or not (run_dir / "catalog.json").is_file():
1644
+ check("semantic_rendered", False, "docs/INDEX.md or catalog.json is missing")
1645
+ return {"ok": False, "checks": checks}
1646
+ try:
1647
+ units, dispositions = _semantic_units(run_dir, state)
1648
+ tree = _semantic_tree(run_dir, state)
1649
+ checks += docs.verify(run_dir, state.get("inventory") or {}, units, dispositions, tree)
1650
+ except (symbols.SemanticError, OSError, ValueError) as exc:
1651
+ check("semantic_checks", False, str(exc)[:300])
1652
+ return {"ok": all(row["ok"] for row in checks), "checks": checks}
1653
+
1654
+
1655
+ def verify(run_dir: Path) -> dict[str, Any]:
1656
+ with locked_state(run_dir) as state:
1657
+ state["verify"] = verify_state(run_dir, state)
1658
+ write_json(state_dir(run_dir) / "verify.json", state["verify"])
1659
+ if state.get("rendered") and all(task["status"] == "done" for task in state["tasks"].values()):
1660
+ state["status"] = "complete" if state["verify"]["ok"] and not state.get("partial_reason") else "partial"
1661
+ summary_path = run_dir / "summary.json"
1662
+ if summary_path.is_file():
1663
+ summary = json.loads(summary_path.read_text(encoding="utf-8"))
1664
+ summary.update(status=state["status"], deterministic_checks_ok=state["verify"]["ok"])
1665
+ write_json(summary_path, summary)
1666
+ return state["verify"]
1667
+
1668
+
1669
+ def render(run_dir: Path) -> dict[str, Any]:
1670
+ """Render now, including placeholders for anything still pending."""
1671
+ with locked_state(run_dir) as state:
1672
+ _render(run_dir, state)
1673
+ if state.get("render_error"):
1674
+ raise HostRunError(f"render failed: {state['render_error']}")
1675
+ return {"index": str(run_dir / "docs" / "INDEX.md"), "status": state["status"], "verify": state["verify"]}
1676
+
1677
+
1678
+ def mark_partial(run_dir: Path, reason: str | None, detail: str = "") -> None:
1679
+ """Keep a finished run from reading "complete" when something outside the tasks says it is not.
1680
+
1681
+ Set or clear ``reason``; the summary, STATUS.md and the render status follow it."""
1682
+ with locked_state(run_dir) as state:
1683
+ if state.get("partial_reason") == reason and state.get("partial_detail", "") == detail:
1684
+ return
1685
+ if reason:
1686
+ state["partial_reason"], state["partial_detail"] = reason, detail
1687
+ log_event(run_dir, "partial", f"{reason}: {detail}", reason=reason)
1688
+ else:
1689
+ state.pop("partial_reason", None)
1690
+ state.pop("partial_detail", None)
1691
+ if state.get("rendered"):
1692
+ _render(run_dir, state)
1693
+
1694
+
1695
+ def finish(run_dir: Path) -> dict[str, Any]:
1696
+ """Make sure the docs reflect the current state; return the run summary."""
1697
+ with locked_state(run_dir) as state:
1698
+ if not state.get("rendered") or state["status"] == "running":
1699
+ _render(run_dir, state)
1700
+ summary_path = run_dir / "summary.json"
1701
+ summary = json.loads(summary_path.read_text(encoding="utf-8")) if summary_path.is_file() else {}
1702
+ return {**summary, **_summary(state), "render_error": state.get("render_error")}
1703
+
1704
+
1705
+ def _status_markdown(state: dict[str, Any]) -> str:
1706
+ counts = Counter(task["status"] for task in state["tasks"].values())
1707
+ unknown = [f"{row['task']} (attempt {row['attempt']})" for row in state.get("usage_unknown") or []]
1708
+ review_tasks = [task for task in state["tasks"].values() if task["kind"] == "review"]
1709
+ review_line = ([f"Semantic review: mode **{state['review']}**; "
1710
+ f"{sum(task['status'] == 'done' for task in review_tasks)}/{len(review_tasks)} "
1711
+ "reviewed by a separate reader of the source. This is independent of the "
1712
+ "deterministic structure/coverage checks and does not prove whole-map truth.", ""]
1713
+ if review_tasks else [])
1714
+ unexplained = _unexplained(state)
1715
+ correction_lines = [f"unexplained {len(unexplained)}", ""]
1716
+ correction_lines += [f"- {row['task']}: {docs.code_text(row['symbol'])}" for row in unexplained]
1717
+ for code in FALLBACK_CODES:
1718
+ if rows := state.get(code, []):
1719
+ correction_lines += ["", f"{code} {len(rows)}", ""]
1720
+ correction_lines += [f"- {docs.code_text(row['path'])}: {row['message']}" for row in rows]
1721
+ lines = ["# CBE run status", "",
1722
+ f"Run `{state['run_tag']}` · status **{state['status']}** · updated {state['updated_at']} · "
1723
+ f"review {state['review']} · jobs {state['jobs']} · host {state['host']}", "",
1724
+ *review_line,
1725
+ *([f"**Partial: {state['partial_reason']}** · {state.get('partial_detail', '')}", ""]
1726
+ if state.get("partial_reason") else []),
1727
+ *(["**Deterministic verification failed** · " + ", ".join(
1728
+ row["check"] for row in state["verify"]["checks"] if not row["ok"])
1729
+ + ". Inspect `cbe host verify`; fix the cause and rerun verification.", ""]
1730
+ if state.get("verify") and not state["verify"]["ok"] else []),
1731
+ *([f"**Usage unknown** · the host reported no usage, so per-call source delivery could not "
1732
+ f"be verified for: {', '.join(unknown)}", ""] if unknown else []),
1733
+ "Counts: " + ", ".join(f"{key} {value}" for key, value in sorted(counts.items())), "",
1734
+ *correction_lines, "",
1735
+ "| Task | Module | State | Attempts | Seconds | Output | Note |", "|---|---|---|---:|---:|---|---|"]
1736
+ for task in sorted(state["tasks"].values(), key=lambda row: (ORDER.get(row["kind"], 0), row["module"] or 0)):
1737
+ output = task["output"] or ""
1738
+ task_unexplained = sum(row["task"] == task["id"] for row in unexplained)
1739
+ shape_flags = [flag for flag in task["flags"] if not flag.startswith("unexplained: ")]
1740
+ note = (task["errors"][-1][:120] if task["status"] in {"failed", "pending"} and task["errors"]
1741
+ else f"normalized ({len(shape_flags)})" if shape_flags else "")
1742
+ if task_unexplained:
1743
+ note += ("; " if note else "") + f"unexplained {task_unexplained}"
1744
+ lines.append(f"| {task['id']} | {_title(state, task) if task['module'] is not None else ''} | "
1745
+ f"{task['status']} | {task['attempts']} | {task['seconds'] if task['seconds'] is not None else ''} | "
1746
+ f"{output} | {note.replace('|', '/')} |")
1747
+ return "\n".join(lines) + "\n"
1748
+
1749
+
1750
+ # ---------------------------------------------------------------- usage and cost
1751
+
1752
+