ltcai 9.9.4 → 9.9.9

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 (138) hide show
  1. package/README.md +43 -42
  2. package/docs/CHANGELOG.md +228 -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 +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +45 -9
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/rerank.py +160 -0
  19. package/lattice_brain/graph/retrieval.py +28 -442
  20. package/lattice_brain/graph/retrieval_reads.py +459 -0
  21. package/lattice_brain/graph/retrieval_vector.py +10 -0
  22. package/lattice_brain/graph/store.py +2 -0
  23. package/lattice_brain/ingestion.py +9 -106
  24. package/lattice_brain/ingestion_jobs.py +133 -0
  25. package/lattice_brain/quality.py +26 -6
  26. package/lattice_brain/runtime/multi_agent.py +1 -1
  27. package/latticeai/__init__.py +1 -1
  28. package/latticeai/api/brain_intelligence.py +8 -0
  29. package/latticeai/api/chat.py +9 -0
  30. package/latticeai/api/chat_agent_http.py +206 -48
  31. package/latticeai/api/chat_contracts.py +10 -1
  32. package/latticeai/api/chat_documents.py +16 -0
  33. package/latticeai/api/evidence_actions.py +48 -0
  34. package/latticeai/api/permission_mode.py +61 -0
  35. package/latticeai/api/project_sessions.py +112 -0
  36. package/latticeai/api/voice_capture.py +89 -0
  37. package/latticeai/app_factory.py +53 -0
  38. package/latticeai/core/agent.py +536 -60
  39. package/latticeai/core/agent_permission.py +160 -0
  40. package/latticeai/core/agent_profiles.py +110 -0
  41. package/latticeai/core/artifact_ledger.py +109 -0
  42. package/latticeai/core/context_builder.py +101 -2
  43. package/latticeai/core/legacy_compatibility.py +1 -1
  44. package/latticeai/core/marketplace.py +1 -1
  45. package/latticeai/core/permission_mode.py +296 -0
  46. package/latticeai/core/project_sessions.py +337 -0
  47. package/latticeai/core/run_explain.py +421 -0
  48. package/latticeai/core/run_store.py +7 -0
  49. package/latticeai/core/workspace_os.py +15 -80
  50. package/latticeai/core/workspace_review_items.py +107 -0
  51. package/latticeai/integrations/telegram_bot.py +270 -23
  52. package/latticeai/runtime/chat_wiring.py +8 -0
  53. package/latticeai/runtime/context_runtime.py +11 -0
  54. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  55. package/latticeai/runtime/router_registration.py +9 -0
  56. package/latticeai/services/app_context.py +3 -0
  57. package/latticeai/services/architecture_readiness.py +1 -1
  58. package/latticeai/services/brain_intelligence.py +105 -0
  59. package/latticeai/services/evidence_actions.py +254 -0
  60. package/latticeai/services/funnel_metrics.py +111 -16
  61. package/latticeai/services/local_knowledge.py +26 -0
  62. package/latticeai/services/memory_service.py +12 -0
  63. package/latticeai/services/permission_mode_service.py +170 -0
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/tool_dispatch.py +129 -4
  66. package/latticeai/services/voice_capture.py +189 -0
  67. package/package.json +6 -3
  68. package/scripts/check_bundle_budget.mjs +11 -0
  69. package/scripts/check_current_release_docs.mjs +1 -1
  70. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  71. package/scripts/run_sidecar_e2e.mjs +159 -0
  72. package/skills/meeting_notes/SKILL.md +82 -0
  73. package/skills/meeting_notes/examples.md +41 -0
  74. package/skills/meeting_notes/risk.json +9 -0
  75. package/skills/meeting_notes/schema.json +55 -0
  76. package/skills/weekly_review/SKILL.md +75 -0
  77. package/skills/weekly_review/examples.md +39 -0
  78. package/skills/weekly_review/risk.json +9 -0
  79. package/skills/weekly_review/schema.json +55 -0
  80. package/src-tauri/Cargo.lock +1 -1
  81. package/src-tauri/Cargo.toml +1 -1
  82. package/src-tauri/tauri.conf.json +1 -1
  83. package/static/app/asset-manifest.json +37 -29
  84. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  85. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  86. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  87. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  88. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  89. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  90. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  91. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  92. package/static/app/assets/Library-BVtrSt12.js +1 -0
  93. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  94. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  95. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  96. package/static/app/assets/System-B4nQDy8a.js +1 -0
  97. package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
  98. package/static/app/assets/brain-wVV63_1z.js +1 -0
  99. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  100. package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
  101. package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
  102. package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
  103. package/static/app/assets/download-EJS5_O0f.js +1 -0
  104. package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
  105. package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
  106. package/static/app/assets/index-BFtDYI6l.js +10 -0
  107. package/static/app/assets/index-BY9bpxyx.css +2 -0
  108. package/static/app/assets/input-DEz5UjBz.js +1 -0
  109. package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
  110. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  111. package/static/app/assets/search-ku6-_6wY.js +1 -0
  112. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  113. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  114. package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
  115. package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
  116. package/static/app/assets/users-C0cFMj6o.js +1 -0
  117. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  118. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  119. package/static/app/index.html +4 -4
  120. package/static/sw.js +1 -1
  121. package/static/app/assets/Act-BCmTU0E2.js +0 -1
  122. package/static/app/assets/Brain-CTnjox7w.js +0 -321
  123. package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
  124. package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
  125. package/static/app/assets/Capture-B6vBhFa3.js +0 -1
  126. package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
  127. package/static/app/assets/Library-pKCK0_tk.js +0 -1
  128. package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
  129. package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
  130. package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
  131. package/static/app/assets/System-BAEuHqNY.js +0 -1
  132. package/static/app/assets/index-7FAfYm4v.css +0 -2
  133. package/static/app/assets/index-DrmOCySv.js +0 -10
  134. package/static/app/assets/input-gtVCg-ll.js +0 -1
  135. package/static/app/assets/primitives-CX2Komon.js +0 -1
  136. package/static/app/assets/shield-alert-BftATuAA.js +0 -1
  137. package/static/app/assets/textarea-CiMJfOSI.js +0 -1
  138. package/static/app/assets/utils-SJUNVOj5.js +0 -7
@@ -31,8 +31,21 @@ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Mapping, Opt
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
34
+ from latticeai.core.agent_permission import (
35
+ block_reason_for_tool,
36
+ non_auto_plan_steps,
37
+ resolve_deps_mode,
38
+ )
39
+ from latticeai.core.agent_profiles import AgentProfile, profile_for_model
34
40
  from latticeai.core.agent_trace import LoopTrace
41
+ from latticeai.core.permission_mode import (
42
+ PermissionMode,
43
+ is_circuit_breaker,
44
+ plan_requires_approval,
45
+ should_stage_proposal,
46
+ )
35
47
  from latticeai.core.file_generation import (
48
+ generate_file_content,
36
49
  infer_file_target,
37
50
  infer_project_manifest,
38
51
  sanitize_write_content,
@@ -67,7 +80,7 @@ class AgentRunContext:
67
80
  __slots__ = ("state", "plan", "transcript", "retry_count",
68
81
  "state_history", "corrections", "final_message", "rollback_log",
69
82
  "executing_model", "reviewing_model", "approved_by_human", "trace",
70
- "on_step")
83
+ "on_step", "project_context", "permission_mode")
71
84
 
72
85
  def __init__(self) -> None:
73
86
  self.state: AgentState = AgentState.IDLE
@@ -86,6 +99,16 @@ class AgentRunContext:
86
99
  # callback here so live SSE clients see progress while EXECUTING.
87
100
  # Never serialized; a broken observer never breaks the loop.
88
101
  self.on_step: Optional[Callable[[Dict[str, Any]], None]] = None
102
+ # Multi-turn project loop (v9.9.6): a prompt block describing where the
103
+ # project stands — files already produced, open TODOs, the last honest
104
+ # verification. Empty for a standalone run, which behaves exactly as
105
+ # before. Set by the HTTP layer, read by plan/execute/verify.
106
+ self.project_context: str = ""
107
+ # Autonomy dial resolved once per run (v9.9.8). The HTTP layer stamps
108
+ # the user/workspace-scoped mode here so the plan gate and every
109
+ # per-tool gate in the same run agree; ``None`` falls back to the
110
+ # process-wide resolver on ``deps``.
111
+ self.permission_mode: Optional[str] = None
89
112
 
90
113
 
91
114
  _THINK_BLOCK_RE = re.compile(
@@ -340,6 +363,146 @@ def compact_transcript(
340
363
  return summarized
341
364
 
342
365
 
366
+ def files_written(
367
+ transcript: List[Dict[str, Any]],
368
+ file_create_actions: FrozenSet[str],
369
+ ) -> List[str]:
370
+ """Ordered unique paths of files this run successfully wrote (review L5).
371
+
372
+ Later executor steps get this as explicit context, so "만들고 이어서
373
+ 설명해" multi-step work sees its own output instead of a stale
374
+ workspace picture.
375
+ """
376
+ seen: List[str] = []
377
+ for step in transcript:
378
+ if step.get("state") != AgentState.EXECUTING.value:
379
+ continue
380
+ if step.get("action") not in file_create_actions:
381
+ continue
382
+ if not isinstance(step.get("result"), dict):
383
+ continue
384
+ path = step["result"].get("path") or (step.get("args") or {}).get("path")
385
+ if path and str(path) not in seen:
386
+ seen.append(str(path))
387
+ return seen
388
+
389
+
390
+ def artifact_checklist(
391
+ transcript: List[Dict[str, Any]],
392
+ file_create_actions: FrozenSet[str],
393
+ ) -> List[Dict[str, Any]]:
394
+ """Deterministic artifact facts for the critic (review L4).
395
+
396
+ The critic previously judged file work from prose alone; this surfaces
397
+ the sanitize/repair honesty flags per written file so a repaired
398
+ placeholder can never pass as a fulfilled request unchecked.
399
+ """
400
+ checklist: List[Dict[str, Any]] = []
401
+ for step in transcript:
402
+ if step.get("state") != AgentState.EXECUTING.value:
403
+ continue
404
+ if step.get("action") not in file_create_actions:
405
+ continue
406
+ if not isinstance(step.get("result"), dict):
407
+ continue
408
+ path = step["result"].get("path") or (step.get("args") or {}).get("path")
409
+ if not path:
410
+ continue
411
+ sanitize_meta = step.get("content_sanitize") or {}
412
+ checklist.append({
413
+ "path": str(path),
414
+ "sanitized": bool(sanitize_meta.get("sanitized")),
415
+ "repaired": bool(sanitize_meta.get("repaired")),
416
+ })
417
+ return checklist
418
+
419
+
420
+ # Explicit requirement lines a user writes out: "- 다크모드", "1. 검색 기능",
421
+ # "* dark mode". Free prose is deliberately NOT parsed — a wrong requirement
422
+ # is worse than no requirement.
423
+ _REQUIREMENT_LINE_RE = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+(.{3,120})$", re.MULTILINE)
424
+
425
+
426
+ def requirement_coverage(
427
+ user_message: str,
428
+ transcript: List[Dict[str, Any]],
429
+ file_create_actions: FrozenSet[str],
430
+ ) -> Dict[str, Any]:
431
+ """Did the run produce what the request actually asked for? (review 루프 §2)
432
+
433
+ The critic judged prose against prose, so "make an HTML+CSS+JS todo app"
434
+ could pass with one file written. This is the deterministic half of the
435
+ answer, built from two sources that are honest about their own limits:
436
+
437
+ * **manifest files** — when the request maps to a known multi-file project
438
+ (:func:`infer_project_manifest`), every declared path must have been
439
+ written. A missing manifest file is a *hard* miss: it is not a matter of
440
+ taste whether ``style.css`` exists.
441
+ * **explicit requirement lines** — bullet/numbered lines the user wrote
442
+ out. These are reported to the critic as a checklist but never block on
443
+ their own: matching a feature to a transcript is a judgement call, and
444
+ guessing it wrong would either fake completion or block real work.
445
+
446
+ Returns ``{"files": {...}, "requirements": [...], "missing_files": [...],
447
+ "complete": bool}`` where ``complete`` is false only when a declared
448
+ manifest file is missing.
449
+ """
450
+ written = files_written(transcript, file_create_actions)
451
+ written_names = {Path(path).name.lower() for path in written}
452
+ manifest = infer_project_manifest(user_message) or {}
453
+ declared = [str(spec.get("path") or "") for spec in manifest.get("files", [])]
454
+ missing = [
455
+ path for path in declared
456
+ if path and Path(path).name.lower() not in written_names
457
+ ]
458
+ requirements = [
459
+ line.strip()
460
+ for line in _REQUIREMENT_LINE_RE.findall(str(user_message or ""))
461
+ ][:10]
462
+ return {
463
+ "files": {"declared": declared, "written": written},
464
+ "missing_files": missing,
465
+ "requirements": requirements,
466
+ "complete": not missing,
467
+ }
468
+
469
+
470
+ def _format_requirement_coverage(coverage: Dict[str, Any]) -> str:
471
+ """Requirement facts for the critic prompt, or "" when there is nothing."""
472
+ lines: List[str] = []
473
+ declared = coverage["files"]["declared"]
474
+ if declared:
475
+ written = set(coverage["files"]["written"])
476
+ lines.append("Requested files (deterministic, from the request):")
477
+ for path in declared:
478
+ got = any(Path(item).name.lower() == Path(path).name.lower() for item in written)
479
+ lines.append(f"- {path}: {'written' if got else 'MISSING'}")
480
+ if coverage["requirements"]:
481
+ lines.append(
482
+ "Requirements the user listed explicitly — check each one against "
483
+ "the artifacts, not against the plan:"
484
+ )
485
+ lines.extend(f"- {item}" for item in coverage["requirements"])
486
+ return "\n\n" + "\n".join(lines) if lines else ""
487
+
488
+
489
+ def _format_artifact_checklist(checklist: List[Dict[str, Any]]) -> str:
490
+ lines = []
491
+ for item in checklist:
492
+ state = (
493
+ "auto-REPAIRED scaffold" if item["repaired"]
494
+ else ("sanitized model output" if item["sanitized"] else "written as produced")
495
+ )
496
+ lines.append(f"- {item['path']}: {state}")
497
+ return (
498
+ "Artifact checklist (deterministic, from the transcript):\n"
499
+ + "\n".join(lines)
500
+ + "\nVerify each artifact actually fulfills the user's request. An "
501
+ "auto-repaired scaffold is NOT completion unless its content "
502
+ "satisfies what was asked."
503
+ )
504
+
505
+
343
506
  @dataclass(frozen=True)
344
507
  class TranscriptBudget:
345
508
  """Executor/critic prompt shaping caps (review Wave 0.3).
@@ -450,6 +613,16 @@ class AgentDeps:
450
613
  # state machine does not shell out directly. Tests can pass a recorder.
451
614
  rollback_file: Optional[Callable[[str], Dict[str, Any]]] = None
452
615
 
616
+ # ── snapshot rollback ports (optional, review L7) ────────────────
617
+ # git-only rollback left non-git workspaces and newly created files
618
+ # unrecoverable. ``snapshot_file(path)`` captures pre-write state
619
+ # ({"existed", "content", "too_large"}) before a file-create action;
620
+ # ``restore_snapshot(path, content)`` restores it (content=None deletes
621
+ # a file the run created). Both are production-wired with workspace
622
+ # path safety; tests pass recorders.
623
+ snapshot_file: Optional[Callable[[str], Dict[str, Any]]] = None
624
+ restore_snapshot: Optional[Callable[[str, Optional[str]], Dict[str, Any]]] = None
625
+
453
626
  # ── lifecycle hooks port (optional) ──────────────────────────────
454
627
  # When present, every tool execution fires the shared pre_tool/post_tool
455
628
  # lifecycle, so the agent tool path no longer bypasses hooks.
@@ -484,6 +657,19 @@ class AgentDeps:
484
657
  # can also be attached on AgentRunContext.on_step. Both are advisory.
485
658
  on_step: Optional[Callable[[Dict[str, Any]], None]] = None
486
659
 
660
+ # ── agent profile (optional, v9.9.7) ─────────────────────────────
661
+ # How hard the loop works to keep a weak model on contract. None selects
662
+ # per-run from the executing model id (``profile_for_model``); tests
663
+ # inject a fixed profile.
664
+ agent_profile: Optional["AgentProfile"] = None
665
+
666
+ # ── permission mode port (optional, v9.9.8) ──────────────────────
667
+ # The autonomy dial. Either a static mode, or a resolver callable that
668
+ # accepts ``user_email``/``workspace_id`` scope kwargs (preferred) or no
669
+ # arguments at all — see ``call_mode_source``. ``None`` means strict,
670
+ # which is exactly the pre-9.9.8 behaviour.
671
+ permission_mode: Any = None
672
+
487
673
 
488
674
  class SingleAgentRuntime:
489
675
  """Drives the agent state machine over injected :class:`AgentDeps`."""
@@ -513,6 +699,44 @@ class SingleAgentRuntime:
513
699
  self._env_transcript_budget = TranscriptBudget.from_env()
514
700
  return self._env_transcript_budget
515
701
 
702
+ # ── permission mode (v9.9.8) ─────────────────────────────────────
703
+ def resolve_permission_mode(
704
+ self,
705
+ ctx: Optional[AgentRunContext] = None,
706
+ *,
707
+ user_email: Optional[str] = None,
708
+ workspace_id: Optional[str] = None,
709
+ ) -> PermissionMode:
710
+ """Autonomy dial for this run.
711
+
712
+ A mode stamped on ``ctx`` wins, so the plan a user approved and every
713
+ tool step in the same run are judged by one dial even if the stored
714
+ preference changes mid-run. Otherwise the resolver on ``deps`` is
715
+ consulted with the caller's scope — resolving unscoped would collapse
716
+ every caller onto the process-wide default.
717
+ """
718
+ return resolve_deps_mode(
719
+ self.deps, ctx, user_email=user_email, workspace_id=workspace_id,
720
+ )
721
+
722
+ def _governed_tools(self) -> FrozenSet[str]:
723
+ governor = getattr(self.deps, "change_governor", None)
724
+ if governor is None:
725
+ return frozenset()
726
+ return frozenset(getattr(governor, "governed_tools", frozenset()))
727
+
728
+ def profile_for(self, model_id: Optional[str]) -> AgentProfile:
729
+ """Loop profile for the model actually executing this run (v9.9.7).
730
+
731
+ An injected ``AgentDeps.agent_profile`` wins (tests, explicit config);
732
+ otherwise the profile is derived from the model id, so a small local
733
+ model gets the compact loop without any extra configuration.
734
+ """
735
+ injected = getattr(self.deps, "agent_profile", None)
736
+ if injected is not None:
737
+ return injected
738
+ return profile_for_model(model_id)
739
+
516
740
  def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
517
741
  """Fire the per-run / deps step observers (review Wave 1.1).
518
742
 
@@ -532,6 +756,17 @@ class SingleAgentRuntime:
532
756
  except Exception as exc: # noqa: BLE001 — observers are advisory
533
757
  logging.warning("agent step observer failed: %s", exc)
534
758
 
759
+ @staticmethod
760
+ def _project_block(ctx: AgentRunContext) -> str:
761
+ """Project-session context for prompts, or "" for a standalone run.
762
+
763
+ Multi-turn project loop (v9.9.6): a later run must see the files the
764
+ project already produced and what is still open, instead of planning
765
+ from a blank workspace every time.
766
+ """
767
+ summary = str(getattr(ctx, "project_context", "") or "").strip()
768
+ return f"\n\n[PROJECT SESSION]\n{summary}" if summary else ""
769
+
535
770
  def boundary(self) -> Dict[str, Any]:
536
771
  return runtime_boundary_contract(
537
772
  name="SingleAgentRuntime",
@@ -561,10 +796,11 @@ class SingleAgentRuntime:
561
796
  ) -> None:
562
797
  """PLAN: Planner role produces a structured plan JSON."""
563
798
  d = self.deps
799
+ project_block = self._project_block(ctx)
564
800
  context = (
565
801
  f"{d.planner_prompt}\n\n"
566
802
  f"[LANGUAGE HINT: {lang_hint}]\n"
567
- f"Workspace root: {d.agent_root}\n\n"
803
+ f"Workspace root: {d.agent_root}{project_block}\n\n"
568
804
  f"User request: {req.message}"
569
805
  )
570
806
  raw = await d.generate_as(
@@ -614,21 +850,20 @@ class SingleAgentRuntime:
614
850
  weakening the gate itself.
615
851
  """
616
852
  d = self.deps
617
- auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
853
+ mode = self.resolve_permission_mode(ctx)
618
854
  # Governor-managed tools never hard-block the plan: each call is
619
855
  # classified at execution time — additive creates run, mutations and
620
856
  # deletions of existing content become review proposals.
621
- governed_tools = (
622
- frozenset(getattr(d.change_governor, "governed_tools", frozenset()))
623
- if d.change_governor is not None else frozenset()
624
- )
857
+ governed_tools = self._governed_tools()
625
858
  steps = ctx.plan.get("steps", [])
626
- non_auto = [
627
- s.get("action") for s in steps
628
- if s.get("action") not in auto_approve_tools
629
- and s.get("action") not in governed_tools
630
- ]
631
- requires = bool(ctx.plan.get("requires_approval", False)) or bool(non_auto)
859
+ non_auto = non_auto_plan_steps(
860
+ mode, steps, d.tool_governance or {}, governed_tools=governed_tools,
861
+ )
862
+ requires = plan_requires_approval(
863
+ mode,
864
+ non_auto_steps=non_auto,
865
+ plan_flag=bool(ctx.plan.get("requires_approval", False)),
866
+ )
632
867
  lines = [
633
868
  f"{index}. {step.get('description') or step.get('action') or '?'}"
634
869
  for index, step in enumerate(steps, start=1)
@@ -639,6 +874,7 @@ class SingleAgentRuntime:
639
874
  return {
640
875
  "requires_approval": requires,
641
876
  "non_auto_steps": non_auto,
877
+ "permission_mode": mode.value,
642
878
  "plan_summary": summary,
643
879
  }
644
880
 
@@ -681,13 +917,16 @@ class SingleAgentRuntime:
681
917
  ) -> None:
682
918
  """EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
683
919
  d = self.deps
920
+ profile = self.profile_for(model_id)
684
921
  exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
685
922
  budget = max(1, max_steps - exec_count)
686
923
  parse_failures = 0
687
924
 
688
925
  for _ in range(budget):
689
926
  request_workspace = getattr(req, "workspace_id", None)
690
- context = self._executor_context(ctx, req, lang_hint, current_user, request_workspace)
927
+ context = self._executor_context(
928
+ ctx, req, lang_hint, current_user, request_workspace, profile=profile
929
+ )
691
930
  raw = await d.generate_as(
692
931
  model_id,
693
932
  message="Execute the next step.",
@@ -700,7 +939,15 @@ class SingleAgentRuntime:
700
939
  ctx.trace.repair("execute", repairs=exec_repairs)
701
940
  except ValueError as exc:
702
941
  parse_failures += 1
703
- if self._note_parse_failure(ctx, raw, exc, parse_failures):
942
+ if self._note_parse_failure(ctx, raw, exc, parse_failures, profile):
943
+ # Direct-path fallback (v9.9.7): a small model that cannot
944
+ # hold the tool-call protocol can still write a file. Run
945
+ # the plan's own file steps without asking for any JSON.
946
+ if profile.direct_path_fallback and await self._direct_file_path(
947
+ ctx, req, current_user, model_id
948
+ ):
949
+ ctx.state = AgentState.VERIFYING
950
+ return
704
951
  break
705
952
  continue
706
953
 
@@ -767,6 +1014,7 @@ class SingleAgentRuntime:
767
1014
  def _executor_context(
768
1015
  self, ctx: AgentRunContext, req: Any, lang_hint: str,
769
1016
  current_user: str, request_workspace: Optional[str],
1017
+ profile: Optional[AgentProfile] = None,
770
1018
  ) -> str:
771
1019
  """Assemble one executor turn's prompt (plan, corrections, recent chat)."""
772
1020
  d = self.deps
@@ -786,16 +1034,26 @@ class SingleAgentRuntime:
786
1034
  recent_kwargs["workspace_id"] = request_workspace
787
1035
  recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
788
1036
  budget = self.transcript_budget
1037
+ # A small model drowns in a long transcript far sooner than a large
1038
+ # one, so the profile may narrow the window (v9.9.7).
1039
+ window = min(budget.window, profile.transcript_window) if profile else budget.window
789
1040
  bounded_transcript = compact_transcript(
790
1041
  ctx.transcript,
791
- window=budget.window,
1042
+ window=window,
792
1043
  result_chars=budget.result_chars,
793
1044
  )
1045
+ # Mid-run workspace awareness (review L5): later steps must see what
1046
+ # this run already produced instead of a stale workspace picture.
1047
+ written = files_written(ctx.transcript, d.file_create_actions)
1048
+ written_hint = (
1049
+ "\n\nFiles written by this run so far (they exist in the workspace now):\n"
1050
+ + "\n".join(f"- {path}" for path in written)
1051
+ ) if written else ""
794
1052
  return (
795
1053
  f"{d.executor_prompt}\n\n"
796
1054
  f"[LANGUAGE HINT: {lang_hint}]\n"
797
- f"Workspace root: {d.agent_root}\n\n"
798
- f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
1055
+ f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
1056
+ f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
799
1057
  f"Recent conversation:\n{recent_conversation}\n\n"
800
1058
  f"User request: {req.message}{corrections_hint}\n\n"
801
1059
  f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
@@ -803,13 +1061,15 @@ class SingleAgentRuntime:
803
1061
 
804
1062
  def _note_parse_failure(
805
1063
  self, ctx: AgentRunContext, raw: Any, exc: ValueError, parse_failures: int,
1064
+ profile: Optional[AgentProfile] = None,
806
1065
  ) -> bool:
807
1066
  """Record one executor parse slip; True when the run should stop retrying."""
1067
+ profile = profile or self.profile_for(None)
808
1068
  ctx.transcript.append({
809
1069
  "state": AgentState.EXECUTING.value, "action": "parse_error",
810
1070
  "raw": str(raw)[:400], "error": str(exc),
811
1071
  })
812
- if parse_failures >= 3:
1072
+ if parse_failures >= profile.parse_failure_budget:
813
1073
  ctx.trace.parse_error("execute", error=str(exc), recovered=False)
814
1074
  self._emit_step(ctx, "execute", "parse_error", recovered=False)
815
1075
  return True
@@ -823,9 +1083,10 @@ class SingleAgentRuntime:
823
1083
  'EXACTLY one JSON object like {"thoughts": "...", "action": '
824
1084
  '"tool_name", "args": {...}} and nothing else.'
825
1085
  )
826
- if parse_failures >= 2:
1086
+ if parse_failures >= profile.escalate_after:
827
1087
  # Escalate: name the valid tools so the model stops
828
- # inventing action names or prose.
1088
+ # inventing action names or prose. The compact profile escalates
1089
+ # a slip earlier — a small model needs the list sooner.
829
1090
  valid = ", ".join(sorted(self.deps.tool_governance.keys()))
830
1091
  hint = (
831
1092
  f"{hint} Valid action values are: {valid}, final. "
@@ -836,6 +1097,84 @@ class SingleAgentRuntime:
836
1097
  ctx.trace.correction("execute", hint=hint)
837
1098
  return False
838
1099
 
1100
+ async def _direct_file_path(
1101
+ self, ctx: AgentRunContext, req: Any, current_user: str,
1102
+ model_id: Optional[str],
1103
+ ) -> bool:
1104
+ """Write the plan's file steps without asking the model for JSON (v9.9.7).
1105
+
1106
+ The compact profile's escape hatch. A 1–4B local model that cannot hold
1107
+ the tool-call protocol can still write a file, so when JSON tool calls
1108
+ are exhausted the loop drops the protocol entirely: it takes the paths
1109
+ the *planner* already chose and asks only for file content in plain
1110
+ text, through the same validated
1111
+ :func:`~latticeai.core.file_generation.generate_file_content` pipeline
1112
+ the direct chat path uses.
1113
+
1114
+ Returns True when at least one file was actually written. Honest
1115
+ failure modes: no planned paths, a governor that stages the write as a
1116
+ proposal, or a tool error all return False and leave the run to end as
1117
+ it would have — this never fabricates evidence.
1118
+ """
1119
+ d = self.deps
1120
+ planned: List[str] = []
1121
+ for step in ctx.plan.get("steps") or []:
1122
+ if not isinstance(step, dict) or step.get("action") not in d.file_create_actions:
1123
+ continue
1124
+ path = str((step.get("args") or {}).get("path") or "").strip()
1125
+ if path and path not in planned:
1126
+ planned.append(path)
1127
+ if not planned:
1128
+ inferred = infer_file_target(getattr(req, "message", "") or "")
1129
+ if inferred:
1130
+ planned = [inferred]
1131
+ if not planned:
1132
+ return False
1133
+
1134
+ goal = str(ctx.plan.get("goal") or getattr(req, "message", "") or "")
1135
+
1136
+ async def _generate(context: str) -> Any:
1137
+ return await d.generate_as(
1138
+ model_id,
1139
+ message="Write the file content.",
1140
+ context=context,
1141
+ max_tokens=self.phase_budgets.execute_tokens,
1142
+ temperature=0.2,
1143
+ )
1144
+
1145
+ wrote = False
1146
+ for path in planned[:6]:
1147
+ try:
1148
+ content, meta = await generate_file_content(
1149
+ _generate,
1150
+ target_path=path,
1151
+ user_request=goal,
1152
+ bundle_files=planned if len(planned) > 1 else None,
1153
+ )
1154
+ except Exception as exc: # noqa: BLE001 — fallback must not raise
1155
+ logging.warning("direct file path generation failed for %s: %s", path, exc)
1156
+ continue
1157
+ ctx.trace.llm_call("execute", model=model_id)
1158
+ ctx.trace.repair("execute", repairs=["direct_path_fallback"])
1159
+ args = {"path": path, "content": content}
1160
+ policy = d.policy_for("write_file", args)
1161
+ risk = d.risk_level(policy)
1162
+ before = len(ctx.transcript)
1163
+ self._dispatch_step(ctx, "write_file", "direct path fallback", args, policy, risk, current_user)
1164
+ last = ctx.transcript[-1] if len(ctx.transcript) > before else {}
1165
+ if isinstance(last.get("result"), dict) and not last["result"].get("proposed"):
1166
+ wrote = True
1167
+ last["direct_path"] = True
1168
+ last["generation"] = {"repaired": bool(meta.get("repaired"))}
1169
+ if wrote:
1170
+ ctx.trace.decision("execute", decision="direct_path_fallback", files=len(planned))
1171
+ self._emit_step(ctx, "execute", "direct_path", files=len(planned))
1172
+ ctx.final_message = (
1173
+ "도구 호출 형식을 계속 벗어나서, 계획에 있던 파일을 직접 생성했습니다. "
1174
+ "내용을 확인해 주세요."
1175
+ )
1176
+ return wrote
1177
+
839
1178
  def _is_repeated_create(self, ctx: AgentRunContext, name: Any, args: dict) -> bool:
840
1179
  """Loop guard: the same file-create action+args re-issued right after a result."""
841
1180
  exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
@@ -858,10 +1197,37 @@ class SingleAgentRuntime:
858
1197
  Returns ``(proposed, governor_allows_additive)``: ``proposed`` means the
859
1198
  step was staged as a proposal (skip execution); ``allows_additive`` lets
860
1199
  an additive create pass the classic approval gate.
1200
+
1201
+ Under a mode that does not stage proposals (``trusted`` / ``bypass``)
1202
+ the decision is made *before* the governor is consulted, because
1203
+ ``review`` persists a proposal as a side effect — reviewing first and
1204
+ discarding the verdict afterwards would apply the change *and* leave an
1205
+ orphan proposal pending in the Review Center.
861
1206
  """
862
1207
  d = self.deps
863
1208
  if d.change_governor is None:
864
1209
  return False, False
1210
+
1211
+ mode = self.resolve_permission_mode(
1212
+ ctx, user_email=current_user, workspace_id=request_workspace,
1213
+ )
1214
+ if not should_stage_proposal(mode, proposal_required=True):
1215
+ if name not in self._governed_tools():
1216
+ return False, False
1217
+ if policy.get("destructive") or policy.get("risk") == "destructive":
1218
+ # Let the destructive gate downstream own the block + transcript.
1219
+ return False, False
1220
+ d.audit(
1221
+ "agent_change_auto_applied",
1222
+ user_email=current_user,
1223
+ workspace_id=request_workspace,
1224
+ action=name,
1225
+ path=str(args.get("path") or "") or None,
1226
+ permission_mode=mode.value,
1227
+ note="permission mode auto-applies mutation with audit",
1228
+ )
1229
+ return False, True
1230
+
865
1231
  verdict = d.change_governor.review(
866
1232
  name, args, policy=dict(policy),
867
1233
  user_email=current_user, workspace_id=request_workspace,
@@ -893,16 +1259,42 @@ class SingleAgentRuntime:
893
1259
  self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
894
1260
  policy: dict, risk: str, current_user: str, governor_allows_additive: bool,
895
1261
  ) -> bool:
896
- """Classic destructive / explicit-approval gates; True when the step was blocked."""
1262
+ """Destructive / circuit-breaker / explicit-approval gates.
1263
+
1264
+ Returns True when the step was blocked. The active permission mode can
1265
+ widen what runs without an extra approval prompt, but never widens a
1266
+ circuit breaker or the destructive gate.
1267
+ """
897
1268
  d = self.deps
898
- if policy["risk"] == "destructive":
1269
+ mode = self.resolve_permission_mode(
1270
+ ctx,
1271
+ user_email=current_user,
1272
+ workspace_id=getattr(req, "workspace_id", None),
1273
+ )
1274
+ # Hard denials first — mode-invariant. A circuit breaker (root/home
1275
+ # paths, `rm -rf /` style commands) and a destructive policy are both
1276
+ # audited as ``blocked`` with the reason that actually fired, rather
1277
+ # than being flattened into the approval path.
1278
+ breaker = is_circuit_breaker(name, policy, args)
1279
+ hard_deny = breaker or (
1280
+ "destructive policy"
1281
+ if policy["risk"] == "destructive" or policy.get("destructive")
1282
+ else None
1283
+ )
1284
+ if hard_deny:
1285
+ error = (
1286
+ f"BLOCKED: destructive action '{name}' not permitted in agent mode."
1287
+ if hard_deny == "destructive policy"
1288
+ else f"BLOCKED: {hard_deny}"
1289
+ )
899
1290
  ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
900
1291
  self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
901
1292
  ctx.transcript.append({
902
1293
  "state": AgentState.EXECUTING.value, "action": name,
903
1294
  "thoughts": thoughts, "args": args, "risk": risk,
904
1295
  "governance": dict(policy),
905
- "error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
1296
+ "permission_mode": mode.value,
1297
+ "error": error,
906
1298
  })
907
1299
  d.audit(
908
1300
  "agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
@@ -910,25 +1302,33 @@ class SingleAgentRuntime:
910
1302
  )
911
1303
  return True
912
1304
 
913
- if not policy["auto_approve"] and not ctx.approved_by_human and not governor_allows_additive:
914
- d.audit(
915
- "agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
916
- state=AgentState.EXECUTING.value, action=name, risk=risk,
917
- shell=policy["shell"], network=policy["network"],
918
- destructive=policy["destructive"], sandbox=policy["sandbox"],
919
- rollback=policy["rollback"],
920
- args={k: v for k, v in args.items() if k != "content"},
921
- )
922
- ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
923
- self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
924
- ctx.transcript.append({
925
- "state": AgentState.EXECUTING.value, "action": name,
926
- "thoughts": thoughts, "args": args, "risk": risk,
927
- "governance": dict(policy),
928
- "error": f"BLOCKED: action '{name}' requires explicit approval.",
929
- })
930
- return True
931
- return False
1305
+ reason = block_reason_for_tool(
1306
+ mode, name, policy, args,
1307
+ approved_by_human=bool(ctx.approved_by_human),
1308
+ governor_allows_additive=governor_allows_additive,
1309
+ )
1310
+ if reason is None:
1311
+ return False
1312
+
1313
+ d.audit(
1314
+ "agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
1315
+ state=AgentState.EXECUTING.value, action=name, risk=risk,
1316
+ shell=policy["shell"], network=policy["network"],
1317
+ destructive=policy["destructive"], sandbox=policy["sandbox"],
1318
+ rollback=policy["rollback"],
1319
+ permission_mode=mode.value,
1320
+ args={k: v for k, v in args.items() if k != "content"},
1321
+ )
1322
+ ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
1323
+ self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
1324
+ ctx.transcript.append({
1325
+ "state": AgentState.EXECUTING.value, "action": name,
1326
+ "thoughts": thoughts, "args": args, "risk": risk,
1327
+ "governance": dict(policy),
1328
+ "permission_mode": mode.value,
1329
+ "error": reason,
1330
+ })
1331
+ return True
932
1332
 
933
1333
  def _dispatch_step(
934
1334
  self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
@@ -961,6 +1361,22 @@ class SingleAgentRuntime:
961
1361
  if s.get("state") == AgentState.EXECUTING.value
962
1362
  and s.get("action") not in (None, "final", "parse_error")
963
1363
  )
1364
+ if (
1365
+ name in d.file_create_actions
1366
+ and d.snapshot_file is not None
1367
+ and args.get("path")
1368
+ ):
1369
+ # Pre-write snapshot (review L7): the first capture per path is
1370
+ # the true pre-run state — later writes to the same path must
1371
+ # not overwrite it. Best-effort: a snapshot failure never
1372
+ # blocks the write, it only narrows rollback options.
1373
+ path_str = str(args["path"])
1374
+ if not any(entry.get("path") == path_str for entry in ctx.rollback_log):
1375
+ try:
1376
+ pre = d.snapshot_file(path_str)
1377
+ ctx.rollback_log.append({"path": path_str, **(pre or {})})
1378
+ except Exception as exc: # noqa: BLE001
1379
+ logging.warning("pre-write snapshot failed for %s: %s", path_str, exc)
964
1380
  try:
965
1381
  d.check_role(name, current_user)
966
1382
  # Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
@@ -1025,11 +1441,25 @@ class SingleAgentRuntime:
1025
1441
  verify_transcript = _truncate_strings(
1026
1442
  ctx.transcript, self.transcript_budget.verify_chars
1027
1443
  )
1444
+ # Deterministic artifact facts (review L4): the critic sees the
1445
+ # sanitize/repair honesty flags per written file, not just prose.
1446
+ checklist = artifact_checklist(ctx.transcript, d.file_create_actions)
1447
+ checklist_hint = (
1448
+ f"\n\n{_format_artifact_checklist(checklist)}" if checklist else ""
1449
+ )
1450
+ # Requirement coverage (review 루프 §2): the critic previously judged
1451
+ # "did this fulfill the request?" from prose alone. It now also sees
1452
+ # which requested files actually exist and which requirements the user
1453
+ # spelled out.
1454
+ coverage = requirement_coverage(
1455
+ req.message, ctx.transcript, d.file_create_actions
1456
+ )
1028
1457
  context = (
1029
1458
  f"{d.critic_prompt}\n\n"
1030
1459
  f"[LANGUAGE HINT: {lang_hint}]\n\n"
1031
1460
  f"Original request: {req.message}\n"
1032
- f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
1461
+ f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}"
1462
+ f"{_format_requirement_coverage(coverage)}\n\n"
1033
1463
  f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
1034
1464
  )
1035
1465
  raw = await d.generate_as(
@@ -1129,6 +1559,24 @@ class SingleAgentRuntime:
1129
1559
  )
1130
1560
  ctx.state = AgentState.NEEDS_REVIEW
1131
1561
  return
1562
+ if not coverage["complete"]:
1563
+ # A PASS that leaves a *requested file* unwritten is not a
1564
+ # completion — this is a fact, not a judgement, so it is
1565
+ # enforced rather than merely reported to the critic.
1566
+ missing = ", ".join(coverage["missing_files"])
1567
+ ctx.trace.decision(
1568
+ "verify", decision="needs_review_missing_files",
1569
+ missing=len(coverage["missing_files"]),
1570
+ )
1571
+ ctx.transcript.append({
1572
+ "state": AgentState.VERIFYING.value,
1573
+ "requirement_coverage": coverage,
1574
+ })
1575
+ ctx.final_message = (
1576
+ f"요청한 파일 중 일부가 만들어지지 않아 완료로 처리하지 않았습니다: {missing}"
1577
+ )
1578
+ ctx.state = AgentState.NEEDS_REVIEW
1579
+ return
1132
1580
  if not ctx.final_message:
1133
1581
  ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
1134
1582
  ctx.state = AgentState.DONE
@@ -1162,40 +1610,68 @@ class SingleAgentRuntime:
1162
1610
  ctx.state = AgentState.FAILED
1163
1611
 
1164
1612
  # ── ROLLBACK ─────────────────────────────────────────────────────
1613
+ def _snapshot_for(self, ctx: AgentRunContext, path: str) -> Optional[Dict[str, Any]]:
1614
+ for entry in ctx.rollback_log:
1615
+ if entry.get("path") == path:
1616
+ return entry
1617
+ return None
1618
+
1619
+ def _rollback_one(self, ctx: AgentRunContext, path: str, gov: Dict[str, Any]) -> Dict[str, Any]:
1620
+ """Recover one path: git when governed and available, else the
1621
+ pre-write snapshot, else an honest ``mode="none"`` (review L7)."""
1622
+ d = self.deps
1623
+ if gov.get("rollback") == "git" and d.rollback_file is not None:
1624
+ try:
1625
+ result = dict(d.rollback_file(str(path)))
1626
+ except Exception as exc: # noqa: BLE001
1627
+ result = {"path": path, "ok": False, "error": str(exc)}
1628
+ if result.get("ok"):
1629
+ result["mode"] = "git"
1630
+ return result
1631
+ snapshot = self._snapshot_for(ctx, str(path))
1632
+ if snapshot is not None and d.restore_snapshot is not None and not snapshot.get("too_large"):
1633
+ content = snapshot.get("content") if snapshot.get("existed") else None
1634
+ try:
1635
+ restored = dict(d.restore_snapshot(str(path), content))
1636
+ except Exception as exc: # noqa: BLE001
1637
+ restored = {"path": path, "ok": False, "error": str(exc)}
1638
+ restored.setdefault("path", path)
1639
+ restored["mode"] = "snapshot"
1640
+ return restored
1641
+ return {
1642
+ "path": path, "ok": False, "mode": "none",
1643
+ "error": "no rollback available (git not applicable, no usable snapshot)",
1644
+ }
1645
+
1165
1646
  def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
1166
- """ROLLBACK: attempt git checkout for each edited file, then FAILED."""
1647
+ """ROLLBACK: recover written files (git snapshot none), then FAILED."""
1167
1648
  d = self.deps
1168
1649
  rolled: List[dict] = []
1650
+ seen_paths: set = set()
1169
1651
  for step in ctx.transcript:
1170
1652
  if step.get("state") != AgentState.EXECUTING.value:
1171
1653
  continue
1172
- gov = step.get("governance", {})
1173
- if gov.get("rollback") != "git":
1654
+ if not isinstance(step.get("result"), dict):
1174
1655
  continue
1175
- result = step.get("result", {})
1176
- if not isinstance(result, dict):
1177
- result = {}
1178
- path = result.get("path") or (step.get("args") or {}).get("path", "")
1179
- if not path:
1656
+ gov = step.get("governance", {}) or {}
1657
+ path = step["result"].get("path") or (step.get("args") or {}).get("path", "")
1658
+ if not path or str(path) in seen_paths:
1180
1659
  continue
1181
- if d.rollback_file is None:
1182
- rolled.append({"path": path, "ok": False, "error": "rollback_file port is not configured"})
1660
+ if gov.get("rollback") != "git" and step.get("action") not in d.file_create_actions:
1183
1661
  continue
1184
- try:
1185
- rolled.append(d.rollback_file(str(path)))
1186
- except Exception as exc:
1187
- rolled.append({"path": path, "ok": False, "error": str(exc)})
1662
+ seen_paths.add(str(path))
1663
+ rolled.append(self._rollback_one(ctx, str(path), gov))
1188
1664
 
1189
1665
  ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
1190
1666
  ctx.trace.decision(
1191
1667
  "rollback", decision="rolled_back",
1192
1668
  attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
1193
1669
  )
1194
- recovered = [r["path"] for r in rolled if r.get("ok")]
1670
+ recovered = [f"{r['path']} ({r.get('mode')})" for r in rolled if r.get("ok")]
1195
1671
  ctx.final_message = (
1196
1672
  f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
1197
1673
  if recovered
1198
- else "롤백을 시도했으나 복구할 파일이 없거나 git 초기화되지 않았습니다."
1674
+ else "롤백을 시도했으나 복구할 파일이 없거나 git/스냅샷 복구 수단이 없습니다."
1199
1675
  )
1200
1676
  d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
1201
1677
  self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))