devcouncil 0.2.0 → 0.3.1
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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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":
|
|
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.
|
|
64
|
-
changed =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
50
|
+
if cfg is None:
|
|
51
|
+
from devcouncil.app.config import load_config
|
|
48
52
|
|
|
49
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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.
|
|
602
|
-
|
|
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)
|