ltcai 9.9.2 → 9.9.3

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 (96) hide show
  1. package/README.md +39 -39
  2. package/docs/CHANGELOG.md +44 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/curator.py +141 -0
  12. package/lattice_brain/graph/documents.py +111 -0
  13. package/lattice_brain/graph/fusion.py +179 -0
  14. package/lattice_brain/graph/projection.py +161 -0
  15. package/lattice_brain/graph/retrieval.py +19 -1
  16. package/lattice_brain/ingestion.py +66 -0
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/automation_intelligence.py +159 -0
  20. package/latticeai/api/browser.py +15 -4
  21. package/latticeai/api/chat.py +27 -2
  22. package/latticeai/api/chat_agent_http.py +191 -3
  23. package/latticeai/api/chat_contracts.py +7 -1
  24. package/latticeai/api/chat_helpers.py +147 -0
  25. package/latticeai/api/chat_intents.py +189 -1
  26. package/latticeai/api/chat_stream.py +17 -1
  27. package/latticeai/api/funnel_metrics.py +31 -0
  28. package/latticeai/api/knowledge_graph.py +30 -0
  29. package/latticeai/api/local_files.py +90 -0
  30. package/latticeai/api/setup.py +134 -1
  31. package/latticeai/api/tools.py +33 -3
  32. package/latticeai/app_factory.py +14 -0
  33. package/latticeai/core/agent.py +91 -9
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +279 -2
  36. package/latticeai/core/legacy_compatibility.py +1 -1
  37. package/latticeai/core/marketplace.py +1 -1
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/runtime/persistence_runtime.py +19 -1
  40. package/latticeai/runtime/router_registration.py +8 -1
  41. package/latticeai/services/app_context.py +3 -0
  42. package/latticeai/services/architecture_readiness.py +1 -1
  43. package/latticeai/services/automation_execution.py +266 -0
  44. package/latticeai/services/automation_intelligence.py +7 -0
  45. package/latticeai/services/command_center.py +20 -1
  46. package/latticeai/services/folder_watch.py +391 -0
  47. package/latticeai/services/funnel_metrics.py +202 -0
  48. package/latticeai/services/product_readiness.py +1 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/setup/demo_corpus.py +98 -0
  51. package/latticeai/tools/filesystem.py +38 -1
  52. package/package.json +1 -1
  53. package/scripts/bench_models.py +234 -5
  54. package/scripts/check_current_release_docs.mjs +1 -1
  55. package/src-tauri/Cargo.lock +1 -1
  56. package/src-tauri/Cargo.toml +1 -1
  57. package/src-tauri/tauri.conf.json +1 -1
  58. package/static/app/asset-manifest.json +28 -27
  59. package/static/app/assets/Act-DmdruVKV.js +1 -0
  60. package/static/app/assets/{Brain-BnGbytHa.js → Brain-DEY9jLVt.js} +2 -2
  61. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  62. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  63. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-CVZ09QXi.js} +1 -1
  64. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  65. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  66. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-DxP4efJF.js} +1 -1
  67. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-DRbm7NEq.js} +1 -1
  68. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  69. package/static/app/assets/{System-DRHc_6Su.js → System-ByQcmJW-.js} +1 -1
  70. package/static/app/assets/{bot-DZkDdh-A.js → bot-BNDyZLR7.js} +1 -1
  71. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  72. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-C6jjYm6i.js} +1 -1
  73. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-DjGIvDBQ.js} +1 -1
  74. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-BlSbwSaT.js} +1 -1
  75. package/static/app/assets/index-Bge3DXW7.css +2 -0
  76. package/static/app/assets/{index-BS_3aN1n.js → index-CHu7cgj3.js} +3 -3
  77. package/static/app/assets/{input-B_U7zvbR.js → input-DVDI0YR3.js} +1 -1
  78. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-BddhEWA0.js} +1 -1
  79. package/static/app/assets/{network-BF13HJJF.js → network-pYQt5oBu.js} +1 -1
  80. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-D7gCdEvS.js} +1 -1
  81. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  82. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-sqQmoBKL.js} +1 -1
  83. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  84. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  85. package/static/app/index.html +4 -4
  86. package/static/sw.js +1 -1
  87. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  88. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  89. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  90. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  91. package/static/app/assets/Library-PcfVUco6.js +0 -1
  92. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  93. package/static/app/assets/download-C7yxYHvz.js +0 -1
  94. package/static/app/assets/index-DU78Kogz.css +0 -2
  95. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  96. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -395,6 +395,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
395
395
  INGESTION_PIPELINE = _persistence_runtime["INGESTION_PIPELINE"]
396
396
  DEVICE_IDENTITY = _persistence_runtime["DEVICE_IDENTITY"]
397
397
  KG_PORTABILITY = _persistence_runtime["KG_PORTABILITY"]
398
+ FUNNEL_METRICS = _persistence_runtime["FUNNEL_METRICS"]
398
399
 
399
400
  def _require_graph():
400
401
  if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
@@ -951,6 +952,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
951
952
  public_model=PUBLIC_MODEL,
952
953
  local_model=LOCAL_MODEL or "",
953
954
  on_chat_message=on_chat_message,
955
+ funnel_metrics=FUNNEL_METRICS,
954
956
  )
955
957
  app.state.context = context
956
958
 
@@ -1036,6 +1038,17 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1036
1038
  gate_write=PLATFORM.gate_write,
1037
1039
  append_audit_event=append_audit_event,
1038
1040
  workspace_graph=_workspace_graph,
1041
+ run_executor=RUN_EXECUTOR,
1042
+ review_queue=REVIEW_QUEUE,
1043
+ )
1044
+ )
1045
+ # UX funnel metrics (backlog #16): admin-only runtime counters.
1046
+ from latticeai.api.funnel_metrics import create_funnel_metrics_router
1047
+
1048
+ app.include_router(
1049
+ create_funnel_metrics_router(
1050
+ service=FUNNEL_METRICS,
1051
+ require_admin=require_admin,
1039
1052
  )
1040
1053
  )
1041
1054
  COMMAND_CENTER = CommandCenterService(
@@ -1212,6 +1225,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1212
1225
  gardener=gardener,
1213
1226
  create_setup_router=create_setup_router,
1214
1227
  model_router=router,
1228
+ knowledge_graph=KNOWLEDGE_GRAPH,
1215
1229
  )
1216
1230
 
1217
1231
  # ── Entry Point ────────────────────────────────────────────────────────────────
@@ -27,7 +27,7 @@ import re
27
27
  from dataclasses import dataclass
28
28
  from enum import Enum
29
29
  from pathlib import Path
30
- from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tuple
30
+ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Mapping, Optional, Tuple
31
31
 
32
32
  from lattice_brain.runtime.hooks import dispatch_tool
33
33
  from lattice_brain.runtime.contracts import runtime_boundary_contract, single_agent_contract
@@ -227,6 +227,44 @@ def filter_learnings(learnings: List[Any]) -> List[str]:
227
227
  return kept
228
228
 
229
229
 
230
+ @dataclass(frozen=True)
231
+ class PhaseBudgets:
232
+ """Per-phase token budgets for the agent loop.
233
+
234
+ One shared budget let a weak model burn everything on planning prose and
235
+ reach EXECUTE with nothing left. Each role phase now has its own cap, so
236
+ a verbose planner can never starve execution or verification. Defaults
237
+ match the historical hardcoded values; every cap is overridable through
238
+ the ``Config.from_env`` environment pattern.
239
+ """
240
+
241
+ plan_tokens: int = 1024
242
+ execute_tokens: int = 4096
243
+ verify_tokens: int = 512
244
+ memory_tokens: int = 256
245
+
246
+ @classmethod
247
+ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "PhaseBudgets":
248
+ from latticeai.core.config import _int
249
+
250
+ if env is None:
251
+ import os
252
+
253
+ env = os.environ
254
+
255
+ def cap(key: str, default: int) -> int:
256
+ # A misconfigured/absurd value must not brick the loop: floor at a
257
+ # budget that still fits one JSON action object.
258
+ return max(128, _int(env, key, default))
259
+
260
+ return cls(
261
+ plan_tokens=cap("LATTICEAI_AGENT_PLAN_TOKENS", cls.plan_tokens),
262
+ execute_tokens=cap("LATTICEAI_AGENT_EXECUTE_TOKENS", cls.execute_tokens),
263
+ verify_tokens=cap("LATTICEAI_AGENT_VERIFY_TOKENS", cls.verify_tokens),
264
+ memory_tokens=cap("LATTICEAI_AGENT_MEMORY_TOKENS", cls.memory_tokens),
265
+ )
266
+
267
+
230
268
  @dataclass
231
269
  class AgentDeps:
232
270
  """The ports a :class:`SingleAgentRuntime` needs from the outside world.
@@ -286,12 +324,29 @@ class AgentDeps:
286
324
  # returning None (fall through to the classic gates) or a verdict dict.
287
325
  change_governor: Any = None
288
326
 
327
+ # ── phase budgets (optional) ─────────────────────────────────────
328
+ # Per-phase token caps (plan/execute/verify/memory). None reads the
329
+ # environment once at first use; tests inject a fixed PhaseBudgets.
330
+ phase_budgets: Optional[PhaseBudgets] = None
331
+
289
332
 
290
333
  class SingleAgentRuntime:
291
334
  """Drives the agent state machine over injected :class:`AgentDeps`."""
292
335
 
293
336
  def __init__(self, deps: AgentDeps) -> None:
294
337
  self.deps = deps
338
+ self._env_phase_budgets: Optional[PhaseBudgets] = None
339
+
340
+ @property
341
+ def phase_budgets(self) -> PhaseBudgets:
342
+ # getattr twice: partially-constructed runtimes/deps (tests build them
343
+ # via __new__ or minimal fakes) still get working default budgets.
344
+ injected = getattr(self.deps, "phase_budgets", None)
345
+ if injected is not None:
346
+ return injected
347
+ if getattr(self, "_env_phase_budgets", None) is None:
348
+ self._env_phase_budgets = PhaseBudgets.from_env()
349
+ return self._env_phase_budgets
295
350
 
296
351
  def boundary(self) -> Dict[str, Any]:
297
352
  return runtime_boundary_contract(
@@ -331,7 +386,7 @@ class SingleAgentRuntime:
331
386
  raw = await d.generate_as(
332
387
  model_id,
333
388
  message="Produce a JSON execution plan for this request.",
334
- context=context, max_tokens=1024, temperature=0.1,
389
+ context=context, max_tokens=self.phase_budgets.plan_tokens, temperature=0.1,
335
390
  )
336
391
  ctx.trace.llm_call("plan", model=model_id)
337
392
  try:
@@ -360,8 +415,14 @@ class SingleAgentRuntime:
360
415
  ctx.state = AgentState.WAITING_APPROVAL
361
416
 
362
417
  # ── APPROVAL ─────────────────────────────────────────────────────
363
- def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
364
- """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
418
+ def approval_requirements(self, ctx: AgentRunContext) -> Dict[str, Any]:
419
+ """Read-only preview of the approval gate for a planned run.
420
+
421
+ Shares the exact predicate :meth:`approve` enforces, so the HTTP
422
+ layer can pause a run as ``awaiting_approval`` (with a plan summary
423
+ for the user) instead of letting it fail closed — without ever
424
+ weakening the gate itself.
425
+ """
365
426
  d = self.deps
366
427
  auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
367
428
  # Governor-managed tools never hard-block the plan: each call is
@@ -377,7 +438,26 @@ class SingleAgentRuntime:
377
438
  if s.get("action") not in auto_approve_tools
378
439
  and s.get("action") not in governed_tools
379
440
  ]
380
- requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
441
+ requires = bool(ctx.plan.get("requires_approval", False)) or bool(non_auto)
442
+ lines = [
443
+ f"{index}. {step.get('description') or step.get('action') or '?'}"
444
+ for index, step in enumerate(steps, start=1)
445
+ ]
446
+ summary = str(ctx.plan.get("goal") or "").strip()
447
+ if lines:
448
+ summary = (summary + "\n" if summary else "") + "\n".join(lines)
449
+ return {
450
+ "requires_approval": requires,
451
+ "non_auto_steps": non_auto,
452
+ "plan_summary": summary,
453
+ }
454
+
455
+ def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
456
+ """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
457
+ d = self.deps
458
+ requirements = self.approval_requirements(ctx)
459
+ non_auto = requirements["non_auto_steps"]
460
+ requires = requirements["requires_approval"]
381
461
 
382
462
  ctx.transcript.append({
383
463
  "state": AgentState.WAITING_APPROVAL.value,
@@ -420,7 +500,8 @@ class SingleAgentRuntime:
420
500
  raw = await d.generate_as(
421
501
  model_id,
422
502
  message="Execute the next step.",
423
- context=context, max_tokens=4096, temperature=req.temperature,
503
+ context=context, max_tokens=self.phase_budgets.execute_tokens,
504
+ temperature=req.temperature,
424
505
  )
425
506
  ctx.trace.llm_call("execute", model=model_id)
426
507
  try:
@@ -727,7 +808,7 @@ class SingleAgentRuntime:
727
808
  raw = await d.generate_as(
728
809
  model_id,
729
810
  message="Review the execution transcript and return your verdict JSON.",
730
- context=context, max_tokens=512, temperature=0.1,
811
+ context=context, max_tokens=self.phase_budgets.verify_tokens, temperature=0.1,
731
812
  )
732
813
  ctx.trace.llm_call("verify", model=model_id)
733
814
  verdict: Optional[Dict[str, Any]] = None
@@ -749,7 +830,8 @@ class SingleAgentRuntime:
749
830
  raw = await d.generate_as(
750
831
  model_id,
751
832
  message="Return your verdict as one strict JSON object.",
752
- context=strict_context, max_tokens=512, temperature=0.0,
833
+ context=strict_context, max_tokens=self.phase_budgets.verify_tokens,
834
+ temperature=0.0,
753
835
  )
754
836
  ctx.trace.llm_call("verify", model=model_id)
755
837
  try:
@@ -899,7 +981,7 @@ class SingleAgentRuntime:
899
981
  try:
900
982
  raw = await d.generate(
901
983
  message="Extract learnings from this completed task.",
902
- context=context, max_tokens=256, temperature=0.1,
984
+ context=context, max_tokens=self.phase_budgets.memory_tokens, temperature=0.1,
903
985
  )
904
986
  mem = extract_action(str(raw))
905
987
  kept_learnings = filter_learnings(mem.get("learnings") or [])
@@ -163,6 +163,18 @@ class Scenario:
163
163
  # canned results (node ids, snippets, proposal ids), so an answer that is
164
164
  # not grounded in the retrieved/executed evidence fails the scenario.
165
165
  expect_final_contains: List[str] = field(default_factory=list)
166
+ # Exact per-name counts against the LoopTrace ``repairs`` histogram —
167
+ # filegen scenarios use this to prove the ArtifactWritePipeline actually
168
+ # fired (``artifact_sanitize`` / ``artifact_repair``), not merely that
169
+ # the tool ran.
170
+ expect_repairs: Dict[str, int] = field(default_factory=dict)
171
+ # Content-level assertions on what file-writing tools actually received
172
+ # AFTER sanitize: every ``contains`` needle must appear in at least one
173
+ # written payload, every ``excludes`` needle in none. This is the teeth
174
+ # of the dirty-write scenarios — a fence or chat wrapper reaching the
175
+ # tool port fails the gate.
176
+ expect_write_contains: List[str] = field(default_factory=list)
177
+ expect_write_excludes: List[str] = field(default_factory=list)
166
178
  max_steps: int = 8
167
179
 
168
180
 
@@ -247,6 +259,37 @@ _WRITE = '{"action": "write_file", "args": {"path": "note.txt", "content": "hi"}
247
259
  _FINAL = '{"action": "final", "message": "done"}'
248
260
  _PASS = '{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "ok"}'
249
261
 
262
+ # ── dirty write_file payloads (ArtifactWritePipeline scenarios) ──────────
263
+ # What weak local models actually put in args.content: chat framing + a
264
+ # Markdown fence around the document. The JSON envelope itself is valid —
265
+ # the dirt is *inside* the content string, so only the write-side sanitize
266
+ # pass (agent.py _dispatch_step → sanitize_write_content) can clean it.
267
+ _DIRTY_HTML_CONTENT = (
268
+ "Sure! Here is your page:\\n"
269
+ "```html\\n"
270
+ "<!DOCTYPE html>\\n<html>\\n<head><meta charset=\\\"utf-8\\\">"
271
+ "<title>Hello</title></head>\\n<body><h1>Hello</h1></body>\\n</html>\\n"
272
+ "```\\n"
273
+ "Hope this helps! Let me know if you need changes."
274
+ )
275
+ _DIRTY_WRITE = (
276
+ '{"thoughts": "produce the page", "action": "write_file", '
277
+ '"args": {"path": "pages/hello.html", "content": "' + _DIRTY_HTML_CONTENT + '"}}'
278
+ )
279
+ # Token-limit casualty: the document just stops mid-body (no </body>/</html>).
280
+ _TRUNCATED_HTML_CONTENT = (
281
+ "<!DOCTYPE html>\\n<html>\\n<head><meta charset=\\\"utf-8\\\">"
282
+ "<title>Weekly</title></head>\\n<body>\\n<h1>Weekly report</h1>"
283
+ )
284
+ _TRUNCATED_WRITE = (
285
+ '{"thoughts": "write the report", "action": "write_file", '
286
+ '"args": {"path": "reports/weekly.html", "content": "' + _TRUNCATED_HTML_CONTENT + '"}}'
287
+ )
288
+ _FILEGEN_PLAN = (
289
+ '{"action": "plan", "goal": "save the requested web page to disk", '
290
+ '"steps": [{"action": "write_file"}]}'
291
+ )
292
+
250
293
 
251
294
  def default_scenarios() -> List[Scenario]:
252
295
  return [
@@ -551,6 +594,54 @@ def default_scenarios() -> List[Scenario]:
551
594
  expected_class="failed",
552
595
  expect_tool_outcomes={"error": 1},
553
596
  ),
597
+ # ── ArtifactWritePipeline (write-side sanitize) ─────────────────
598
+ Scenario(
599
+ name="filegen-dirty-write-sanitized-critic-pass",
600
+ # Weak model wraps args.content in prose + a Markdown fence. The
601
+ # sanitize pass must strip the wrapper BEFORE the tool runs, the
602
+ # trace must record artifact_sanitize, and the critic PASS then
603
+ # completes over a transcript whose written payload is clean.
604
+ replies=[_FILEGEN_PLAN, _DIRTY_WRITE, _FINAL, _PASS],
605
+ expect_exact={"parse_errors": 0},
606
+ expect_tool_outcomes={"ok": 1},
607
+ expect_tool_calls=["write_file"],
608
+ expect_repairs={"artifact_sanitize": 1},
609
+ expect_write_contains=["<!DOCTYPE html>", "</html>"],
610
+ expect_write_excludes=["```", "Sure!", "Hope this helps"],
611
+ ),
612
+ Scenario(
613
+ name="filegen-truncated-write-repaired-critic-pass",
614
+ # Token-limit truncation: no extractable clean document exists, so
615
+ # the pipeline's deterministic repair must close the document
616
+ # (artifact_repair) — the file on disk is still structurally valid.
617
+ replies=[_FILEGEN_PLAN, _TRUNCATED_WRITE, _FINAL, _PASS],
618
+ expect_exact={"parse_errors": 0},
619
+ expect_tool_outcomes={"ok": 1},
620
+ expect_tool_calls=["write_file"],
621
+ expect_repairs={"artifact_repair": 1},
622
+ expect_write_contains=["<h1>Weekly report</h1>", "</body>", "</html>"],
623
+ expect_write_excludes=["```"],
624
+ ),
625
+ Scenario(
626
+ name="filegen-dirty-write-unverifiable-needs-review",
627
+ # The dirty write is sanitized and runs, but the critic never
628
+ # produces a parseable verdict — fail-closed: the run must end
629
+ # NEEDS_REVIEW (never a fabricated DONE), while the written file
630
+ # stays clean because sanitize ran before the tool.
631
+ replies=[
632
+ _FILEGEN_PLAN, _DIRTY_WRITE, _FINAL,
633
+ "the page looks great, approving!", # unparseable critic
634
+ "still prose, still not a JSON verdict", # strict retry fails too
635
+ ],
636
+ expect_state="NEEDS_REVIEW",
637
+ expected_class="needs_review",
638
+ expect_min={"parse_errors": 2},
639
+ expect_tool_outcomes={"ok": 1},
640
+ expect_tool_calls=["write_file"],
641
+ expect_repairs={"artifact_sanitize": 1},
642
+ expect_write_excludes=["```", "Sure!"],
643
+ expect_final_contains=["직접 확인"],
644
+ ),
554
645
  ]
555
646
 
556
647
 
@@ -591,6 +682,22 @@ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
591
682
  for needle in scenario.expect_final_contains:
592
683
  if needle not in ctx.final_message:
593
684
  failures.append(f"final_message missing {needle!r}")
685
+ for repair_name, count in scenario.expect_repairs.items():
686
+ seen = int((summary.get("repairs") or {}).get(repair_name, 0))
687
+ if seen != count:
688
+ failures.append(f"repairs[{repair_name}]={seen} != {count}")
689
+ # Payloads the file-writing tools actually received (post-sanitize).
690
+ written = [
691
+ str((call.get("args") or {}).get("content") or "")
692
+ for call in tool_log
693
+ if call["name"] in ("write_file", "generate_file")
694
+ ]
695
+ for needle in scenario.expect_write_contains:
696
+ if not any(needle in payload for payload in written):
697
+ failures.append(f"written content missing {needle!r}")
698
+ for needle in scenario.expect_write_excludes:
699
+ if any(needle in payload for payload in written):
700
+ failures.append(f"written content must not contain {needle!r}")
594
701
  if governor is not None and summary["tool_outcomes"].get("proposed", 0) != len(governor.proposals):
595
702
  failures.append(
596
703
  f"governor proposals={len(governor.proposals)} != "