devcouncil 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import asyncio
5
+ import logging
5
6
  import time
6
7
  from pathlib import Path
7
8
 
@@ -32,6 +33,7 @@ from devcouncil.telemetry.traces import TraceLogger
32
33
 
33
34
  app = typer.Typer(help="Review active coding-agent sessions and emit critique cards.")
34
35
  console = Console()
36
+ logger = logging.getLogger(__name__)
35
37
 
36
38
 
37
39
  @app.command("sessions")
@@ -72,6 +74,9 @@ def review(
72
74
  ):
73
75
  """Review the latest assistant response in a coding-agent transcript."""
74
76
  root = project_root.expanduser().resolve()
77
+ from devcouncil.telemetry.logging_setup import set_log_dir
78
+ set_log_dir(root)
79
+ logger.info("dev watch review: client=%s llm=%s", client, llm)
75
80
  transcript_path = _resolve_transcript(root, client, transcript=transcript, session=session, latest=latest)
76
81
  if transcript_path is None:
77
82
  message = "No transcript selected. Use --transcript, --session, or --latest."
@@ -91,6 +96,7 @@ def review(
91
96
 
92
97
  scoped_task_id = task_id or active_task_id(root)
93
98
  card = asyncio.run(_review_turn(turn, root, client, llm, task_id=scoped_task_id))
99
+ logger.info("dev watch review: card %s verdict=%s task=%s", card.id, card.verdict, scoped_task_id or "(unscoped)")
94
100
  saved_path, duplicate = _save_card_once(root, card, persist=persist, force=force)
95
101
  if saved_path:
96
102
  _log_card_reviewed(root, card, saved_path, duplicate=duplicate, source="review")
@@ -279,10 +285,13 @@ def repair_all(
279
285
  """Generate repair prompts for all blocking live-review cards in scope."""
280
286
  root = project_root.expanduser().resolve()
281
287
  summary = live_review_summary(root, task_id=task_id)
288
+ all_cards = {card.id: card for card in load_cards(root)}
289
+ # Guard the membership test with isinstance(str): a malformed (non-hashable) id would
290
+ # otherwise raise TypeError on `in`, whereas the old per-id lookup just skipped it.
282
291
  cards = [
283
- get_card(root, item["id"])
292
+ all_cards[item["id"]]
284
293
  for item in summary["blocking_cards"]
285
- if isinstance(item.get("id"), str)
294
+ if isinstance(item.get("id"), str) and item["id"] in all_cards
286
295
  ]
287
296
  resolved_cards = [card for card in cards if card is not None]
288
297
  prompt = build_bulk_live_repair_prompt(root, resolved_cards)
@@ -331,6 +340,10 @@ def pending(
331
340
  ):
332
341
  """Review every pending response-ready signal that includes a transcript path."""
333
342
  root = project_root.expanduser().resolve()
343
+ from devcouncil.telemetry.logging_setup import set_log_dir
344
+ set_log_dir(root)
345
+ logger.info("dev watch pending: reviewing signals (client=%s, llm=%s)", client or "all", llm)
346
+ fallback_task_id = active_task_id(root)
334
347
  reviewed = []
335
348
  skipped = []
336
349
  for signal in _filtered_signals(root, client):
@@ -342,7 +355,7 @@ def pending(
342
355
  if turn is None:
343
356
  skipped.append({"signal": signal.model_dump(), "reason": f"No assistant turn found in {transcript_path}."})
344
357
  continue
345
- scoped_task_id = task_id or signal.task_id or active_task_id(root)
358
+ scoped_task_id = task_id or signal.task_id or fallback_task_id
346
359
  card = asyncio.run(_review_turn(turn, root, signal.client, llm, task_id=scoped_task_id))
347
360
  saved_path, duplicate = _save_card_once(root, card, persist=True, force=force)
348
361
  if saved_path:
@@ -363,6 +376,7 @@ def pending(
363
376
  else:
364
377
  console.print(f"[green]Saved critique card:[/green] {saved_path}")
365
378
 
379
+ logger.info("dev watch pending complete: %d reviewed, %d skipped", len(reviewed), len(skipped))
366
380
  if json_format:
367
381
  typer.echo(json.dumps({"reviewed": reviewed, "skipped": skipped}, indent=2))
368
382
  return
@@ -385,11 +399,15 @@ def follow(
385
399
  ):
386
400
  """Poll a transcript and emit a critique card whenever the latest assistant turn changes."""
387
401
  root = project_root.expanduser().resolve()
402
+ from devcouncil.telemetry.logging_setup import set_log_dir
403
+ set_log_dir(root)
388
404
  transcript_path = _resolve_transcript(root, client, transcript=transcript, session=session, latest=latest)
389
405
  if transcript_path is None:
406
+ logger.warning("dev watch follow: no transcript selected")
390
407
  console.print("[red]No transcript selected. Use --transcript, --session, or --latest.[/red]")
391
408
  raise typer.Exit(code=2)
392
409
  seen_turn_id: str | None = None
410
+ logger.info("dev watch follow: watching %s (client=%s, interval=%ss, llm=%s)", transcript_path, client, interval, llm)
393
411
  console.print(f"[cyan]Watching transcript:[/cyan] {transcript_path}")
394
412
  while True:
395
413
  turn = latest_assistant_turn(transcript_path, client=client)
@@ -397,6 +415,7 @@ def follow(
397
415
  seen_turn_id = turn.turn_id
398
416
  scoped_task_id = task_id or active_task_id(root)
399
417
  card = asyncio.run(_review_turn(turn, root, client, llm, task_id=scoped_task_id))
418
+ logger.info("dev watch follow: new turn %s → card %s verdict=%s", turn.turn_id, card.id, card.verdict)
400
419
  saved_path, duplicate = _save_card_once(root, card, persist=True, force=force)
401
420
  if saved_path:
402
421
  _log_card_reviewed(root, card, saved_path, duplicate=duplicate, source="follow")
@@ -564,10 +583,11 @@ async def _review_turn(turn, root: Path, client: str, use_llm: bool, task_id: st
564
583
  config = load_config(root)
565
584
  validate_model_provider(config.models.provider)
566
585
  api_key = get_api_key(config.models.provider, root)
567
- provider = create_provider(config.models.provider, api_key, project_root=root)
586
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
568
587
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
569
588
  router = ModelRouter(provider, role_config, project_root=root)
570
589
  except Exception as exc:
590
+ logger.warning("Model-backed live review unavailable; using deterministic card: %s", exc)
571
591
  console.print(f"[yellow]Model-backed review unavailable; using deterministic card: {exc}[/yellow]")
572
592
  return review_turn(turn, root, client=client, task_id=task_id)
573
593
  card = await LiveReviewService(router).review(turn, root, client=client, use_llm=True)
@@ -37,14 +37,17 @@ from devcouncil.cli.commands import ( # noqa: E402 - imports follow stdio recon
37
37
  cost,
38
38
  ast,
39
39
  dashboard,
40
+ design,
40
41
  doctor,
41
42
  go,
42
43
  hook,
43
44
  init,
44
45
  integrate,
46
+ logs,
45
47
  lsp,
46
48
  map,
47
49
  mcp_server,
50
+ okf,
48
51
  plan,
49
52
  prompt,
50
53
  repair,
@@ -91,6 +94,7 @@ app.add_typer(mcp_server.app, name="mcp-server")
91
94
  app.add_typer(integrate.app, name="integrate")
92
95
  app.add_typer(integrate.app, name="integrations")
93
96
  app.add_typer(trace.app, name="trace")
97
+ app.add_typer(logs.app, name="logs")
94
98
  app.add_typer(cost.app, name="cost")
95
99
  app.add_typer(runs.app, name="runs")
96
100
  app.add_typer(setup.app, name="setup")
@@ -101,6 +105,8 @@ app.add_typer(watch.app, name="watch")
101
105
  app.add_typer(semantic.app, name="semantic")
102
106
  app.add_typer(evidence.app, name="evidence")
103
107
  app.add_typer(skills.app, name="skills")
108
+ app.add_typer(okf.app, name="okf")
109
+ app.add_typer(design.app, name="design")
104
110
  watch.app.command("fs")(watch_fs)
105
111
 
106
112
  # Direct command registrations (those defined as def cmd())
@@ -127,10 +133,39 @@ app.command(name="status")(status.status)
127
133
  app.command(name="optimize")(agents.optimize_agent)
128
134
 
129
135
  @app.callback()
130
- def main(ctx: typer.Context):
136
+ def main(
137
+ ctx: typer.Context,
138
+ verbose: int = typer.Option(
139
+ 0,
140
+ "--verbose",
141
+ "-v",
142
+ count=True,
143
+ help="Increase console log verbosity (-v INFO, -vv DEBUG). Everything is "
144
+ "always captured at DEBUG in .devcouncil/logs/devcouncil.log.",
145
+ ),
146
+ quiet: bool = typer.Option(
147
+ False,
148
+ "--quiet",
149
+ "-q",
150
+ help="Only show errors on the console (the log file still captures everything).",
151
+ ),
152
+ log_level: str = typer.Option(
153
+ None,
154
+ "--log-level",
155
+ help="Explicit console log level (DEBUG/INFO/WARNING/ERROR). Overrides -v/-q "
156
+ "and the DEVCOUNCIL_LOG_LEVEL env var.",
157
+ ),
158
+ ):
131
159
  """
132
160
  DevCouncil: Gated orchestrator for AI-assisted software development.
133
161
  """
162
+ # Configure logging once, up front, for every command. Without this the many
163
+ # logger.info/debug calls across the orchestrator, planner, executors and
164
+ # verifier go nowhere — which is exactly why recurring run failures were so
165
+ # hard to diagnose. The durable DEBUG log lands in .devcouncil/logs/.
166
+ from devcouncil.telemetry.logging_setup import configure_logging
167
+
168
+ configure_logging(verbosity=verbose, quiet=quiet, log_level=log_level)
134
169
  return
135
170
 
136
171
  if __name__ == "__main__":
@@ -42,6 +42,13 @@ class VerificationEvidence(BaseModel):
42
42
  command: str
43
43
  status: Literal["passed", "failed", "not_run"]
44
44
  evidence_summary: str
45
+ # HOW the criterion was proven, for auditing the gate's rigor (distinct from the
46
+ # pass/fail status). ``compiled`` = one DevCouncil per-criterion check passed;
47
+ # ``vote`` = a majority of independent checks passed (self-consistency);
48
+ # ``coarse`` = proven only by a passing acceptance-capable command, not a check tied
49
+ # to the criterion (weakest). Empty for legacy/unspecified evidence. Persisted in the
50
+ # evidence JSON blob, so adding it needs no migration; old rows default to "".
51
+ mode: Literal["compiled", "vote", "coarse", ""] = ""
45
52
 
46
53
  # Backward-compatible alias
47
54
  TestEvidence = VerificationEvidence
@@ -47,6 +47,7 @@ class CheckpointService:
47
47
  return self._create(task_id, stage="attempt", ref_name=ref_template)
48
48
 
49
49
  def rollback(self, task_id: str) -> CheckpointResult:
50
+ logger.info("Rollback requested for %s", task_id)
50
51
  before_ref = self.REF_BEFORE.format(task_id=task_id)
51
52
  after_ref = self.REF_AFTER.format(task_id=task_id)
52
53
  after_patch = self.checkpoint_dir / f"{task_id}-after.patch"
@@ -140,8 +141,9 @@ class CheckpointService:
140
141
  # the git-ref rollback path can never fire. Falls back to HEAD if snapshotting is
141
142
  # impossible (unborn HEAD / not a git repo), preserving the patch-based rollback.
142
143
  git_ref_created = self._update_ref(ref, self._snapshot_commit())
144
+ verifier = Verifier(self.project_root)
143
145
  try:
144
- diff = Verifier(self.project_root).get_diff()
146
+ diff = verifier.get_diff()
145
147
  if diff:
146
148
  patch_path.write_text(diff, encoding="utf-8")
147
149
  except Exception as exc:
@@ -152,12 +154,20 @@ class CheckpointService:
152
154
  if stage == "before":
153
155
  snapshot = {
154
156
  "task_id": task_id,
155
- "changed_files": Verifier(self.project_root).get_changed_files(),
157
+ "changed_files": verifier.get_changed_files(),
156
158
  }
157
159
  snapshot_path = self.checkpoint_dir / f"{task_id}-before.json"
158
160
  snapshot_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8")
159
161
  json_path = str(snapshot_path)
160
162
 
163
+ # Routine bookkeeping (fires before+after every task and repair attempt) — DEBUG
164
+ # keeps the -v stream milestone-level; the file still records it, and a capture
165
+ # *failure* is logged at WARNING above.
166
+ logger.debug(
167
+ "Checkpoint %s for %s: ref=%s patch=%s",
168
+ stage, task_id, "yes" if git_ref_created else "no",
169
+ "yes" if patch_path.exists() else "no",
170
+ )
161
171
  return CheckpointResult(
162
172
  task_id=task_id,
163
173
  ref=ref if git_ref_created else None,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  import time
6
7
  import uuid
7
8
  from pathlib import Path
@@ -37,6 +38,8 @@ _EVENT_IGNORED_PREFIXES = _IGNORED_PREFIXES + (".devcouncil/", ".gitignore")
37
38
  _TASK_CACHE_TTL_SECONDS = 10.0
38
39
  _EVENT_DEBOUNCE_SECONDS = 0.5
39
40
 
41
+ logger = logging.getLogger(__name__)
42
+
40
43
 
41
44
  class FilesystemWatcher:
42
45
  def __init__(
@@ -53,15 +56,20 @@ class FilesystemWatcher:
53
56
  self.on_event = on_event
54
57
  self.policy = TaskPolicyEngine(self.project_root)
55
58
  self._seen: dict[str, float] = {}
59
+ self._seen_last_cleanup: float = 0.0
56
60
  self._task_cache: tuple[float, Task | None] | None = None
61
+ # Reuse one Verifier across polls (avoids rebuilding its scanners each tick) but
62
+ # still run get_changed_files() fresh every poll — caching the git result would
63
+ # make the watcher miss changes for the cache TTL, defeating the poll interval.
64
+ self._verifier: Verifier | None = None
57
65
 
58
66
  def should_ignore(self, path: str) -> bool:
59
67
  normalized = path.replace("\\", "/")
60
68
  return any(normalized.startswith(prefix) for prefix in _IGNORED_PREFIXES)
61
69
 
62
70
  def scan_once(self) -> list[dict]:
63
- task = self._load_task()
64
- changed = Verifier(self.project_root).get_changed_files()
71
+ task = self._task_cached()
72
+ changed = self._changed_files()
65
73
  events: list[dict] = []
66
74
  for path in changed:
67
75
  if self.should_ignore(path):
@@ -71,6 +79,8 @@ class FilesystemWatcher:
71
79
 
72
80
  def watch(self) -> None:
73
81
  observer = self._start_event_observer()
82
+ mode = "polling" if observer is None else "event-driven (watchdog)"
83
+ logger.info("Filesystem watcher started for %s in %s mode", self.task_id, mode)
74
84
  if observer is None:
75
85
  # Polling fallback when watchdog is unavailable.
76
86
  while True:
@@ -129,6 +139,12 @@ class FilesystemWatcher:
129
139
  now = time.monotonic()
130
140
  last = self._seen.get(rel)
131
141
  self._seen[rel] = now
142
+ # Periodically evict stale entries so _seen doesn't grow unbounded in long watch
143
+ # sessions. Anything older than the debounce window can never suppress a future
144
+ # event, so dropping it leaves dedup behavior unchanged. Run at most once/window.
145
+ if now - self._seen_last_cleanup >= window:
146
+ self._seen = {key: ts for key, ts in self._seen.items() if now - ts < window}
147
+ self._seen_last_cleanup = now
132
148
  return last is not None and (now - last) < window
133
149
 
134
150
  def _task_cached(self) -> Task | None:
@@ -139,6 +155,11 @@ class FilesystemWatcher:
139
155
  self._task_cache = (now, task)
140
156
  return task
141
157
 
158
+ def _changed_files(self) -> list[str]:
159
+ if self._verifier is None:
160
+ self._verifier = Verifier(self.project_root)
161
+ return self._verifier.get_changed_files()
162
+
142
163
  def _load_task(self) -> Task | None:
143
164
  db = get_db(self.project_root)
144
165
  if not db:
@@ -165,6 +186,10 @@ class FilesystemWatcher:
165
186
  reason=decision.reason,
166
187
  )
167
188
  if not allowed:
189
+ logger.warning(
190
+ "Orphan diff for %s: %s %s (%s)",
191
+ self.task_id, operation, path, decision.reason,
192
+ )
168
193
  gap_repo = GapRepository(session)
169
194
  gap_repo.save(
170
195
  Gap(
@@ -55,7 +55,7 @@ class HandoffService:
55
55
  if not task:
56
56
  raise ValueError(f"Task {task_id} not found")
57
57
  reqs = RequirementRepository(session).get_all()
58
- gaps = [g for g in GapRepository(session).get_all() if g.task_id == task_id and g.blocking]
58
+ gaps = GapRepository(session).get_blocking_for_task(task_id)
59
59
  evidence = EvidenceRepository(session).get_command_results_for_task(task_id)
60
60
  semantic = SemanticDiffRepository(session).latest_for_task(task_id)
61
61
 
@@ -1,8 +1,11 @@
1
+ import logging
1
2
  import re
2
3
  import subprocess
3
4
  from pathlib import Path
4
5
  from devcouncil.app.errors import ExecutionError
5
6
 
7
+ logger = logging.getLogger(__name__)
8
+
6
9
 
7
10
  class PatchEngine:
8
11
  """Handles applying unified diff patches to the codebase."""
@@ -66,8 +69,11 @@ class PatchEngine:
66
69
  errors="replace",
67
70
  )
68
71
  if proc.returncode == 0:
72
+ logger.info("Patch applied (git apply %s)", " ".join(extra_args) or "strict")
69
73
  return True
70
74
  last_stderr = (proc.stderr or "").strip()
75
+ logger.debug("git apply %s failed: %s", " ".join(extra_args) or "strict", last_stderr)
76
+ logger.error("Patch failed after all fallbacks: %s", last_stderr or "(no detail)")
71
77
  raise ExecutionError(
72
78
  "Failed to apply patch even with whitespace/3-way fallbacks. "
73
79
  f"git reported: {last_stderr or '(no detail)'}"
@@ -1,4 +1,5 @@
1
1
  import fnmatch
2
+ import logging
2
3
  from pathlib import Path
3
4
  from typing import List, Literal, Optional
4
5
  from pydantic import BaseModel, Field
@@ -6,6 +7,8 @@ from devcouncil.domain.task import PlannedFile, Task
6
7
  from devcouncil.app.errors import GatingError
7
8
  from devcouncil.execution.policy_engine import TaskPolicyEngine
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
  class PermissionPolicy(BaseModel):
10
13
  """Defines the security boundaries for task execution."""
11
14
  allow_file_create: bool = False
@@ -76,10 +79,14 @@ class PermissionManager:
76
79
  """Raise GatingError if an execution action violates permissions."""
77
80
  if action_type == "file_write":
78
81
  if not self.is_file_change_allowed(target, task, operation, internal=internal):
82
+ logger.warning("DENIED file %s for %s: %s (not in planned_files)", operation, task.id, target)
79
83
  raise GatingError(
80
84
  f"Unauthorized file {operation}: {target}. "
81
85
  "File and operation must match task planned_files."
82
86
  )
87
+ logger.debug("Allowed file %s for %s: %s", operation, task.id, target)
83
88
  elif action_type == "shell":
84
89
  if not self.is_command_allowed(target, task):
90
+ logger.warning("DENIED shell command for %s: %s (not in allowed_commands)", task.id, target)
85
91
  raise GatingError(f"Unauthorized shell command: {target}. Command must be in task's allowed_commands.")
92
+ logger.debug("Allowed shell command for %s: %s", task.id, target)
@@ -11,6 +11,15 @@ from pydantic import BaseModel
11
11
 
12
12
  from devcouncil.domain.task import PlannedFile, Task
13
13
 
14
+ # Precompiled git-safety patterns for hook-command evaluation (compiled once at import
15
+ # instead of on every evaluate_hook_command call).
16
+ _HARD_RESET_PROTECTED_RE = re.compile(r"\bgit\s+reset\s+--hard\s+(origin/)?(main|master)\b")
17
+ _FORCE_PUSH_FLAG_RE = re.compile(r"\bgit\s+push\b.*(\s--force(?:-with-lease)?\b|\s-f\b)")
18
+ _FORCE_PUSH_PLUS_REFSPEC_RE = re.compile(r"\bgit\s+push\s+\S+\s+\+\S")
19
+ _PROTECTED_BRANCH_PUSH_RE = re.compile(
20
+ r"\bgit\s+push\s+\S+\s+((head:)?(main|master)|(main|master):\S+)\b"
21
+ )
22
+
14
23
 
15
24
  class PolicyDecision(BaseModel):
16
25
  action: Literal["allow", "warn", "deny"]
@@ -288,16 +297,14 @@ class TaskPolicyEngine:
288
297
  target=normalized,
289
298
  )
290
299
 
291
- if re.search(r"\bgit\s+reset\s+--hard\s+(origin/)?(main|master)\b", lowered):
300
+ if _HARD_RESET_PROTECTED_RE.search(lowered):
292
301
  return PolicyDecision(
293
302
  action="deny",
294
303
  reason="Protected branch hard resets are not allowed.",
295
304
  target=normalized,
296
305
  )
297
306
 
298
- if re.search(r"\bgit\s+push\b.*(\s--force(?:-with-lease)?\b|\s-f\b)", lowered) or re.search(
299
- r"\bgit\s+push\s+\S+\s+\+\S", lowered
300
- ):
307
+ if _FORCE_PUSH_FLAG_RE.search(lowered) or _FORCE_PUSH_PLUS_REFSPEC_RE.search(lowered):
301
308
  # The second pattern catches the leading-plus refspec form
302
309
  # (`git push origin +HEAD:master`), which forces a non-fast-forward update
303
310
  # without the --force flag.
@@ -307,7 +314,7 @@ class TaskPolicyEngine:
307
314
  target=normalized,
308
315
  )
309
316
 
310
- if re.search(r"\bgit\s+push\s+\S+\s+((head:)?(main|master)|(main|master):\S+)\b", lowered):
317
+ if _PROTECTED_BRANCH_PUSH_RE.search(lowered):
311
318
  return PolicyDecision(
312
319
  action="warn",
313
320
  reason="Direct pushes to protected branches should go through verification gates.",
@@ -33,7 +33,7 @@ _RESERVED_COMPLETION_TOKENS = 1536
33
33
  _MIN_PROMPT_CHARS = 8_000
34
34
 
35
35
 
36
- def _local_context_window_budget(project_root: Path) -> int | None:
36
+ def _local_context_window_budget(project_root: Path, cfg=None) -> int | None:
37
37
  """Char budget derived from a constrained local context window, or ``None``.
38
38
 
39
39
  When the run targets the local Ollama provider with an explicit ``OLLAMA_NUM_CTX``,
@@ -42,11 +42,16 @@ def _local_context_window_budget(project_root: Path) -> int | None:
42
42
  budget that fits the window (minus completion headroom) so :meth:`build_task_prompt`
43
43
  can cap itself. Returns ``None`` for cloud providers / unset windows, leaving the
44
44
  default behavior (and the large cloud CLIs' big windows) untouched. Best-effort:
45
- any error degrades to ``None``."""
45
+ any error degrades to ``None``.
46
+
47
+ ``cfg`` may be a pre-loaded config (loaded once per task by ``build_task_prompt``); when
48
+ ``None`` it is loaded here so other callers keep working."""
46
49
  try:
47
- from devcouncil.app.config import load_config
50
+ if cfg is None:
51
+ from devcouncil.app.config import load_config
48
52
 
49
- provider = load_config(project_root).models.provider.strip().lower()
53
+ cfg = load_config(project_root)
54
+ provider = cfg.models.provider.strip().lower()
50
55
  if provider not in {"ollama", "ollama-local", "ollama_local"}:
51
56
  return None
52
57
  except Exception:
@@ -88,10 +93,25 @@ class PromptBuilder:
88
93
  Python uses stdlib ``ast`` (method signatures, async/@property/@staticmethod
89
94
  markers under each class). Other languages (ts/tsx/js/jsx/go/rs/java) use bounded
90
95
  regex over exported/public declarations. No tree-sitter, no model call. Never
91
- raises; honors the per-file symbol cap."""
96
+ raises; honors the per-file symbol cap.
97
+
98
+ Results are memoized per ``build_task_prompt`` run via ``self._outline_cache`` so
99
+ the same file is parsed once even though both the planned-files section and the
100
+ call-sites section need its outline. The key includes the ``text`` itself (not just
101
+ ``path``) so that if the file's content differs between the two reads, the outline
102
+ is recomputed from the current text rather than served stale — and using the text
103
+ directly (rather than its hash) means there is no collision risk."""
104
+ cache = getattr(self, "_outline_cache", None)
105
+ key = (path, text)
106
+ if cache is not None and key in cache:
107
+ return cache[key]
92
108
  if path.endswith(".py"):
93
- return self._python_symbol_outline(text)
94
- return self._regex_symbol_outline(path, text)
109
+ result = self._python_symbol_outline(text)
110
+ else:
111
+ result = self._regex_symbol_outline(path, text)
112
+ if cache is not None:
113
+ cache[key] = result
114
+ return result
95
115
 
96
116
  def _python_symbol_outline(self, text: str) -> List[str]:
97
117
  try:
@@ -252,6 +272,15 @@ class PromptBuilder:
252
272
  + "\n".join(blocks)
253
273
  )
254
274
 
275
+ def _load_prompt_enhancement(self):
276
+ """Latest run's prompt-enhancement (None if absent/unreadable). Best-effort: a
277
+ failure here must never break prompt construction."""
278
+ try:
279
+ from devcouncil.planning.prompt_enhancer_service import load_latest_prompt_enhancement
280
+ return load_latest_prompt_enhancement(self.project_root)
281
+ except Exception:
282
+ return None
283
+
255
284
  def _load_repo_map(self) -> dict | None:
256
285
  """Parse ``.devcouncil/repo_map.json`` once per prompt (None if absent/unreadable)."""
257
286
  map_path = self.project_root / ".devcouncil" / "repo_map.json"
@@ -361,6 +390,56 @@ class PromptBuilder:
361
390
  section += f"- `{skill.name}`{suffix}\n"
362
391
  return section
363
392
 
393
+ def _knowledge_sections(self, task: Task, cfg=None) -> tuple[str, str]:
394
+ """Selected design-system and OKF knowledge context for this task.
395
+
396
+ Returns ``(design_text, knowledge_text)`` — either may be empty. Sourced from
397
+ ``.devcouncil/knowledge/{design,okf}`` via the same trigger-based selection the
398
+ skills library uses: a design system is always-on (a UI agent must honor it),
399
+ OKF knowledge fires on goal keywords / document tags. Bounded by config char
400
+ budgets. Never raises — a knowledge failure must not break prompt building.
401
+
402
+ ``cfg`` may be a pre-loaded config (loaded once per task by ``build_task_prompt``);
403
+ when ``None`` it is loaded here so other callers keep working."""
404
+ try:
405
+ from devcouncil.knowledge.sources import (
406
+ render_knowledge_preamble,
407
+ select_knowledge_sources,
408
+ )
409
+
410
+ if cfg is None:
411
+ from devcouncil.app.config import load_config
412
+
413
+ cfg = load_config(self.project_root)
414
+ kcfg = cfg.knowledge
415
+ if not kcfg.enabled:
416
+ return "", ""
417
+ goal = f"{task.title}\n{task.description}"
418
+ sources = select_knowledge_sources(
419
+ goal=goal, project_root=self.project_root,
420
+ directory=kcfg.directory, design_always=kcfg.design_always,
421
+ )
422
+ design_text = render_knowledge_preamble(sources, max_chars=kcfg.design_max_chars, kind="design")
423
+ knowledge_text = render_knowledge_preamble(sources, max_chars=kcfg.okf_max_chars, kind="okf")
424
+ except Exception:
425
+ return "", ""
426
+
427
+ design_block = ""
428
+ if design_text:
429
+ design_block = (
430
+ "\n## Design system (honor these tokens and rules)\n"
431
+ "_The project's design.md. Use these tokens/components; don't invent ad-hoc styles._\n\n"
432
+ f"{design_text}\n"
433
+ )
434
+ knowledge_block = ""
435
+ if knowledge_text:
436
+ knowledge_block = (
437
+ "\n## Project knowledge (Open Knowledge Format)\n"
438
+ "_Curated org/domain knowledge relevant to this task. Ground your work in it._\n\n"
439
+ f"{knowledge_text}\n"
440
+ )
441
+ return design_block, knowledge_block
442
+
364
443
  def _dependents_section(self, task: Task, data: dict | None) -> str:
365
444
  """List, per planned file the agent will change, the files that import it — the
366
445
  blast radius. Sourced from repo_map.json's precomputed reverse-import index, so
@@ -549,10 +628,22 @@ class PromptBuilder:
549
628
  ) -> str:
550
629
  if max_chars is None:
551
630
  max_chars = MAX_PROMPT_CHARS
631
+ # Per-task outline cache so a planned file's symbol outline is computed once even
632
+ # though both the planned-files section and the call-sites section consume it.
633
+ self._outline_cache: dict[str, List[str]] = {}
634
+ # Load the project config once and share it with the helpers that need it, instead
635
+ # of each independently re-reading + re-parsing it. Best-effort: if it fails the
636
+ # helpers fall back to loading it themselves (and degrade the same way).
637
+ try:
638
+ from devcouncil.app.config import load_config
639
+
640
+ cfg = load_config(self.project_root)
641
+ except Exception:
642
+ cfg = None
552
643
  # When the run targets a constrained local window (Ollama + OLLAMA_NUM_CTX),
553
644
  # cap the budget so the server doesn't silently truncate past the window. Never
554
645
  # raises the budget above the caller's value — only lowers it to fit.
555
- window_budget = _local_context_window_budget(self.project_root)
646
+ window_budget = _local_context_window_budget(self.project_root, cfg=cfg)
556
647
  if window_budget is not None:
557
648
  max_chars = min(max_chars, window_budget)
558
649
 
@@ -572,6 +663,18 @@ class PromptBuilder:
572
663
  for ac in req.acceptance_criteria:
573
664
  core += f" - [ ] {ac.description} ({ac.verification_method})\n"
574
665
 
666
+ # Carry the planning prompt-enhancer's codebase-specific constraints through to the
667
+ # one who writes the code. Otherwise that domain guidance (e.g. "division truncates
668
+ # toward zero", "no eval") shapes only the planning debate and reaches the executor
669
+ # only if a planner happened to encode it into an acceptance criterion.
670
+ enhancement = self._load_prompt_enhancement()
671
+ if enhancement is not None and (enhancement.constraints or enhancement.applied_skills):
672
+ core += "\n## Codebase-specific constraints (from planning — honor these)\n"
673
+ for constraint in enhancement.constraints[:8]:
674
+ core += f"- {constraint}\n"
675
+ if enhancement.applied_skills:
676
+ core += f"- Apply current senior-level practices for: {', '.join(enhancement.applied_skills[:6])}.\n"
677
+
575
678
  core += "\n## Allowed files\n"
576
679
  for pf in task.planned_files:
577
680
  core += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
@@ -598,8 +701,12 @@ class PromptBuilder:
598
701
  1. Implement the goal described above.
599
702
  2. Ensure all acceptance criteria are met.
600
703
  3. Only modify the allowed files.
601
- 4. Run the allowed commands to verify your work.
602
- 5. Provide evidence of passing tests.
704
+ 4. Stay within this task's scope even inside an allowed file: change only what the
705
+ acceptance criteria require. Do NOT remove, rename, or alter the signature of an
706
+ existing public symbol the task did not ask you to touch — verification flags an
707
+ unrequested public-API change as scope drift and blocks it.
708
+ 5. Run the allowed commands to verify your work.
709
+ 6. Provide evidence of passing tests.
603
710
  """
604
711
 
605
712
  # --- Optional context: fitted within the remaining budget, dropped lowest-
@@ -644,6 +751,15 @@ class PromptBuilder:
644
751
  if skills_text:
645
752
  segments.append({"order": 4, "priority": 3, "name": "engineering skills", "text": skills_text})
646
753
 
754
+ # Design system (a hard constraint for UI work) and OKF project knowledge. The
755
+ # design system rides just above skills; OKF knowledge alongside them. Both are
756
+ # bounded by config char budgets in `_knowledge_sections`.
757
+ design_text, knowledge_text = self._knowledge_sections(task, cfg=cfg)
758
+ if design_text:
759
+ segments.append({"order": 4, "priority": 2, "name": "design system", "text": design_text})
760
+ if knowledge_text:
761
+ segments.append({"order": 4, "priority": 3, "name": "project knowledge", "text": knowledge_text})
762
+
647
763
  # Lowest priority (4): the budget drops call sites first. It only adds value once
648
764
  # the file bodies + dependents are present anyway.
649
765
  call_sites_text = self._call_sites_section(task, repo_map_data)