ltcai 9.9.5 → 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 (132) hide show
  1. package/README.md +41 -36
  2. package/docs/CHANGELOG.md +193 -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 +38 -8
  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/retrieval_vector.py +10 -0
  19. package/lattice_brain/ingestion.py +9 -106
  20. package/lattice_brain/ingestion_jobs.py +133 -0
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +8 -0
  24. package/latticeai/api/chat.py +9 -0
  25. package/latticeai/api/chat_agent_http.py +119 -2
  26. package/latticeai/api/chat_contracts.py +9 -0
  27. package/latticeai/api/chat_documents.py +16 -0
  28. package/latticeai/api/evidence_actions.py +48 -0
  29. package/latticeai/api/permission_mode.py +61 -0
  30. package/latticeai/api/project_sessions.py +112 -0
  31. package/latticeai/api/voice_capture.py +89 -0
  32. package/latticeai/app_factory.py +53 -0
  33. package/latticeai/core/agent.py +381 -43
  34. package/latticeai/core/agent_permission.py +160 -0
  35. package/latticeai/core/agent_profiles.py +110 -0
  36. package/latticeai/core/artifact_ledger.py +109 -0
  37. package/latticeai/core/context_builder.py +101 -2
  38. package/latticeai/core/legacy_compatibility.py +1 -1
  39. package/latticeai/core/marketplace.py +1 -1
  40. package/latticeai/core/permission_mode.py +296 -0
  41. package/latticeai/core/project_sessions.py +337 -0
  42. package/latticeai/core/run_explain.py +421 -0
  43. package/latticeai/core/run_store.py +5 -0
  44. package/latticeai/core/workspace_os.py +15 -80
  45. package/latticeai/core/workspace_review_items.py +107 -0
  46. package/latticeai/integrations/telegram_bot.py +188 -0
  47. package/latticeai/runtime/chat_wiring.py +8 -0
  48. package/latticeai/runtime/context_runtime.py +11 -0
  49. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  50. package/latticeai/runtime/router_registration.py +9 -0
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/brain_intelligence.py +105 -0
  54. package/latticeai/services/evidence_actions.py +254 -0
  55. package/latticeai/services/funnel_metrics.py +111 -16
  56. package/latticeai/services/local_knowledge.py +26 -0
  57. package/latticeai/services/memory_service.py +12 -0
  58. package/latticeai/services/permission_mode_service.py +170 -0
  59. package/latticeai/services/product_readiness.py +1 -1
  60. package/latticeai/services/tool_dispatch.py +73 -4
  61. package/latticeai/services/voice_capture.py +189 -0
  62. package/package.json +5 -3
  63. package/scripts/check_bundle_budget.mjs +11 -0
  64. package/scripts/check_current_release_docs.mjs +1 -1
  65. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  66. package/skills/meeting_notes/SKILL.md +82 -0
  67. package/skills/meeting_notes/examples.md +41 -0
  68. package/skills/meeting_notes/risk.json +9 -0
  69. package/skills/meeting_notes/schema.json +55 -0
  70. package/skills/weekly_review/SKILL.md +75 -0
  71. package/skills/weekly_review/examples.md +39 -0
  72. package/skills/weekly_review/risk.json +9 -0
  73. package/skills/weekly_review/schema.json +55 -0
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -29
  78. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  79. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  80. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  81. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  82. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  83. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  84. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  85. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  86. package/static/app/assets/Library-BVtrSt12.js +1 -0
  87. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  88. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  89. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  90. package/static/app/assets/System-B4nQDy8a.js +1 -0
  91. package/static/app/assets/{bot-VmP0kkeA.js → bot-DtKkLfXj.js} +1 -1
  92. package/static/app/assets/brain-wVV63_1z.js +1 -0
  93. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  94. package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause--qxF9-EO.js} +1 -1
  95. package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-89x3S6jm.js} +1 -1
  96. package/static/app/assets/{cpu-CJhjRtNq.js → cpu-DTUI63vo.js} +1 -1
  97. package/static/app/assets/download-EJS5_O0f.js +1 -0
  98. package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-BL9zYTnc.js} +1 -1
  99. package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-BFnwi1Lu.js} +1 -1
  100. package/static/app/assets/index-BFtDYI6l.js +10 -0
  101. package/static/app/assets/index-BY9bpxyx.css +2 -0
  102. package/static/app/assets/input-DEz5UjBz.js +1 -0
  103. package/static/app/assets/{network-Ck1nqc-v.js → network-zKa3jAwl.js} +1 -1
  104. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  105. package/static/app/assets/search-ku6-_6wY.js +1 -0
  106. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  107. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  108. package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-58TNCONM.js} +1 -1
  109. package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-CcVlXlBd.js} +1 -1
  110. package/static/app/assets/users-C0cFMj6o.js +1 -0
  111. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  112. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/Act-DmxfbqBp.js +0 -1
  116. package/static/app/assets/Brain-DK_zKCda.js +0 -321
  117. package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
  118. package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
  119. package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
  120. package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
  121. package/static/app/assets/Library-4As9VUvy.js +0 -1
  122. package/static/app/assets/LivingBrain-BVWk58MW.js +0 -1
  123. package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
  124. package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
  125. package/static/app/assets/System-DSR2zKWd.js +0 -1
  126. package/static/app/assets/index-7FAfYm4v.css +0 -2
  127. package/static/app/assets/index-CEu0Wqjl.js +0 -10
  128. package/static/app/assets/input-CSi6OBJ9.js +0 -1
  129. package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
  130. package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
  131. package/static/app/assets/textarea-C4wM0D-Z.js +0 -1
  132. package/static/app/assets/utils-BqoznzGP.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(
@@ -394,6 +417,75 @@ def artifact_checklist(
394
417
  return checklist
395
418
 
396
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
+
397
489
  def _format_artifact_checklist(checklist: List[Dict[str, Any]]) -> str:
398
490
  lines = []
399
491
  for item in checklist:
@@ -565,6 +657,19 @@ class AgentDeps:
565
657
  # can also be attached on AgentRunContext.on_step. Both are advisory.
566
658
  on_step: Optional[Callable[[Dict[str, Any]], None]] = None
567
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
+
568
673
 
569
674
  class SingleAgentRuntime:
570
675
  """Drives the agent state machine over injected :class:`AgentDeps`."""
@@ -594,6 +699,44 @@ class SingleAgentRuntime:
594
699
  self._env_transcript_budget = TranscriptBudget.from_env()
595
700
  return self._env_transcript_budget
596
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
+
597
740
  def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
598
741
  """Fire the per-run / deps step observers (review Wave 1.1).
599
742
 
@@ -613,6 +756,17 @@ class SingleAgentRuntime:
613
756
  except Exception as exc: # noqa: BLE001 — observers are advisory
614
757
  logging.warning("agent step observer failed: %s", exc)
615
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
+
616
770
  def boundary(self) -> Dict[str, Any]:
617
771
  return runtime_boundary_contract(
618
772
  name="SingleAgentRuntime",
@@ -642,10 +796,11 @@ class SingleAgentRuntime:
642
796
  ) -> None:
643
797
  """PLAN: Planner role produces a structured plan JSON."""
644
798
  d = self.deps
799
+ project_block = self._project_block(ctx)
645
800
  context = (
646
801
  f"{d.planner_prompt}\n\n"
647
802
  f"[LANGUAGE HINT: {lang_hint}]\n"
648
- f"Workspace root: {d.agent_root}\n\n"
803
+ f"Workspace root: {d.agent_root}{project_block}\n\n"
649
804
  f"User request: {req.message}"
650
805
  )
651
806
  raw = await d.generate_as(
@@ -695,21 +850,20 @@ class SingleAgentRuntime:
695
850
  weakening the gate itself.
696
851
  """
697
852
  d = self.deps
698
- auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
853
+ mode = self.resolve_permission_mode(ctx)
699
854
  # Governor-managed tools never hard-block the plan: each call is
700
855
  # classified at execution time — additive creates run, mutations and
701
856
  # deletions of existing content become review proposals.
702
- governed_tools = (
703
- frozenset(getattr(d.change_governor, "governed_tools", frozenset()))
704
- if d.change_governor is not None else frozenset()
705
- )
857
+ governed_tools = self._governed_tools()
706
858
  steps = ctx.plan.get("steps", [])
707
- non_auto = [
708
- s.get("action") for s in steps
709
- if s.get("action") not in auto_approve_tools
710
- and s.get("action") not in governed_tools
711
- ]
712
- 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
+ )
713
867
  lines = [
714
868
  f"{index}. {step.get('description') or step.get('action') or '?'}"
715
869
  for index, step in enumerate(steps, start=1)
@@ -720,6 +874,7 @@ class SingleAgentRuntime:
720
874
  return {
721
875
  "requires_approval": requires,
722
876
  "non_auto_steps": non_auto,
877
+ "permission_mode": mode.value,
723
878
  "plan_summary": summary,
724
879
  }
725
880
 
@@ -762,13 +917,16 @@ class SingleAgentRuntime:
762
917
  ) -> None:
763
918
  """EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
764
919
  d = self.deps
920
+ profile = self.profile_for(model_id)
765
921
  exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
766
922
  budget = max(1, max_steps - exec_count)
767
923
  parse_failures = 0
768
924
 
769
925
  for _ in range(budget):
770
926
  request_workspace = getattr(req, "workspace_id", None)
771
- 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
+ )
772
930
  raw = await d.generate_as(
773
931
  model_id,
774
932
  message="Execute the next step.",
@@ -781,7 +939,15 @@ class SingleAgentRuntime:
781
939
  ctx.trace.repair("execute", repairs=exec_repairs)
782
940
  except ValueError as exc:
783
941
  parse_failures += 1
784
- 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
785
951
  break
786
952
  continue
787
953
 
@@ -848,6 +1014,7 @@ class SingleAgentRuntime:
848
1014
  def _executor_context(
849
1015
  self, ctx: AgentRunContext, req: Any, lang_hint: str,
850
1016
  current_user: str, request_workspace: Optional[str],
1017
+ profile: Optional[AgentProfile] = None,
851
1018
  ) -> str:
852
1019
  """Assemble one executor turn's prompt (plan, corrections, recent chat)."""
853
1020
  d = self.deps
@@ -867,9 +1034,12 @@ class SingleAgentRuntime:
867
1034
  recent_kwargs["workspace_id"] = request_workspace
868
1035
  recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
869
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
870
1040
  bounded_transcript = compact_transcript(
871
1041
  ctx.transcript,
872
- window=budget.window,
1042
+ window=window,
873
1043
  result_chars=budget.result_chars,
874
1044
  )
875
1045
  # Mid-run workspace awareness (review L5): later steps must see what
@@ -882,7 +1052,7 @@ class SingleAgentRuntime:
882
1052
  return (
883
1053
  f"{d.executor_prompt}\n\n"
884
1054
  f"[LANGUAGE HINT: {lang_hint}]\n"
885
- f"Workspace root: {d.agent_root}\n\n"
1055
+ f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
886
1056
  f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
887
1057
  f"Recent conversation:\n{recent_conversation}\n\n"
888
1058
  f"User request: {req.message}{corrections_hint}\n\n"
@@ -891,13 +1061,15 @@ class SingleAgentRuntime:
891
1061
 
892
1062
  def _note_parse_failure(
893
1063
  self, ctx: AgentRunContext, raw: Any, exc: ValueError, parse_failures: int,
1064
+ profile: Optional[AgentProfile] = None,
894
1065
  ) -> bool:
895
1066
  """Record one executor parse slip; True when the run should stop retrying."""
1067
+ profile = profile or self.profile_for(None)
896
1068
  ctx.transcript.append({
897
1069
  "state": AgentState.EXECUTING.value, "action": "parse_error",
898
1070
  "raw": str(raw)[:400], "error": str(exc),
899
1071
  })
900
- if parse_failures >= 3:
1072
+ if parse_failures >= profile.parse_failure_budget:
901
1073
  ctx.trace.parse_error("execute", error=str(exc), recovered=False)
902
1074
  self._emit_step(ctx, "execute", "parse_error", recovered=False)
903
1075
  return True
@@ -911,9 +1083,10 @@ class SingleAgentRuntime:
911
1083
  'EXACTLY one JSON object like {"thoughts": "...", "action": '
912
1084
  '"tool_name", "args": {...}} and nothing else.'
913
1085
  )
914
- if parse_failures >= 2:
1086
+ if parse_failures >= profile.escalate_after:
915
1087
  # Escalate: name the valid tools so the model stops
916
- # 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.
917
1090
  valid = ", ".join(sorted(self.deps.tool_governance.keys()))
918
1091
  hint = (
919
1092
  f"{hint} Valid action values are: {valid}, final. "
@@ -924,6 +1097,84 @@ class SingleAgentRuntime:
924
1097
  ctx.trace.correction("execute", hint=hint)
925
1098
  return False
926
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
+
927
1178
  def _is_repeated_create(self, ctx: AgentRunContext, name: Any, args: dict) -> bool:
928
1179
  """Loop guard: the same file-create action+args re-issued right after a result."""
929
1180
  exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
@@ -946,10 +1197,37 @@ class SingleAgentRuntime:
946
1197
  Returns ``(proposed, governor_allows_additive)``: ``proposed`` means the
947
1198
  step was staged as a proposal (skip execution); ``allows_additive`` lets
948
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.
949
1206
  """
950
1207
  d = self.deps
951
1208
  if d.change_governor is None:
952
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
+
953
1231
  verdict = d.change_governor.review(
954
1232
  name, args, policy=dict(policy),
955
1233
  user_email=current_user, workspace_id=request_workspace,
@@ -981,16 +1259,42 @@ class SingleAgentRuntime:
981
1259
  self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
982
1260
  policy: dict, risk: str, current_user: str, governor_allows_additive: bool,
983
1261
  ) -> bool:
984
- """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
+ """
985
1268
  d = self.deps
986
- 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
+ )
987
1290
  ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
988
1291
  self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
989
1292
  ctx.transcript.append({
990
1293
  "state": AgentState.EXECUTING.value, "action": name,
991
1294
  "thoughts": thoughts, "args": args, "risk": risk,
992
1295
  "governance": dict(policy),
993
- "error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
1296
+ "permission_mode": mode.value,
1297
+ "error": error,
994
1298
  })
995
1299
  d.audit(
996
1300
  "agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
@@ -998,25 +1302,33 @@ class SingleAgentRuntime:
998
1302
  )
999
1303
  return True
1000
1304
 
1001
- if not policy["auto_approve"] and not ctx.approved_by_human and not governor_allows_additive:
1002
- d.audit(
1003
- "agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
1004
- state=AgentState.EXECUTING.value, action=name, risk=risk,
1005
- shell=policy["shell"], network=policy["network"],
1006
- destructive=policy["destructive"], sandbox=policy["sandbox"],
1007
- rollback=policy["rollback"],
1008
- args={k: v for k, v in args.items() if k != "content"},
1009
- )
1010
- ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
1011
- self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
1012
- ctx.transcript.append({
1013
- "state": AgentState.EXECUTING.value, "action": name,
1014
- "thoughts": thoughts, "args": args, "risk": risk,
1015
- "governance": dict(policy),
1016
- "error": f"BLOCKED: action '{name}' requires explicit approval.",
1017
- })
1018
- return True
1019
- 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
1020
1332
 
1021
1333
  def _dispatch_step(
1022
1334
  self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
@@ -1135,11 +1447,19 @@ class SingleAgentRuntime:
1135
1447
  checklist_hint = (
1136
1448
  f"\n\n{_format_artifact_checklist(checklist)}" if checklist else ""
1137
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
+ )
1138
1457
  context = (
1139
1458
  f"{d.critic_prompt}\n\n"
1140
1459
  f"[LANGUAGE HINT: {lang_hint}]\n\n"
1141
1460
  f"Original request: {req.message}\n"
1142
- f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}\n\n"
1461
+ f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}"
1462
+ f"{_format_requirement_coverage(coverage)}\n\n"
1143
1463
  f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
1144
1464
  )
1145
1465
  raw = await d.generate_as(
@@ -1239,6 +1559,24 @@ class SingleAgentRuntime:
1239
1559
  )
1240
1560
  ctx.state = AgentState.NEEDS_REVIEW
1241
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
1242
1580
  if not ctx.final_message:
1243
1581
  ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
1244
1582
  ctx.state = AgentState.DONE