devcouncil 0.4.0 → 0.4.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 +49 -6
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/cli/commands/watch.py +7 -10
- package/src/devcouncil/indexing/viz.py +11 -7
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +1 -1
- package/src/devcouncil/integrations/mcp/handlers/git.py +199 -17
- package/src/devcouncil/integrations/mcp/handlers/graph.py +6 -8
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +6 -8
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +25 -39
- package/src/devcouncil/integrations/mcp/handlers/read.py +30 -1
- package/src/devcouncil/integrations/mcp/handlers/runs.py +3 -18
- package/src/devcouncil/integrations/mcp/handlers/status.py +117 -32
- package/src/devcouncil/integrations/mcp/handlers/task.py +5 -17
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +28 -10
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +4 -11
- package/src/devcouncil/integrations/mcp/server.py +1 -1
- package/src/devcouncil/integrations/mcp/util.py +27 -5
- package/src/devcouncil/live/summary.py +21 -3
- package/uv.lock +26 -26
package/README.md
CHANGED
|
@@ -1,26 +1,65 @@
|
|
|
1
1
|
# DevCouncil: The Gated AI Orchestrator
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<img src="https://raw.githubusercontent.com/bharathvbcr/DevCouncil/main/src/devcouncil/assets/
|
|
4
|
+
<img src="https://raw.githubusercontent.com/bharathvbcr/DevCouncil/main/src/devcouncil/assets/devcouncil_social_preview.jpg" alt="DevCouncil gated AI orchestration" width="720">
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.python.org/downloads/)
|
|
9
9
|
[](https://github.com/astral-sh/uv)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## OpenAI Build Week 2026
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Coding agents -- including Codex and other prompt-taking CLIs -- often claim success without proving that the change satisfied the original requirements. DevCouncil turns that claim into a gated engineering workflow: every change is scoped, verified, and traceable back to a requirement. Model confidence is not the final authority; evidence is.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
### What judges should see
|
|
16
|
+
|
|
17
|
+
1. **Provider-free red-to-green evidence gate** -- install once, then watch a controlled sample fail verification and pass after a real fix, with no API keys.
|
|
18
|
+
2. **Self-contained interactive code graph** -- open the packed `demo.html` artifact and navigate filters, path highlighting, and neighborhoods (no blank canvas).
|
|
19
|
+
3. **Codex / MCP agent-control path** -- status, diffs, and task tools stay correct under real project-sized JSON so an agent can resume from evidence instead of chat memory.
|
|
20
|
+
|
|
21
|
+
### Judge path (intended package: `devcouncil@0.4.1`)
|
|
22
|
+
|
|
23
|
+
DevCouncil supports macOS, Linux, and Windows. Requires Node.js 18+, Python 3.12+, and Git. No model provider key is needed for the deterministic demos below.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Prefer the Build Week release once published:
|
|
27
|
+
npm install -g devcouncil@0.4.1
|
|
28
|
+
devcouncil --help
|
|
29
|
+
|
|
30
|
+
# Core demo: red verdict -> apply fix -> green verdict (no API keys)
|
|
31
|
+
git clone https://github.com/bharathvbcr/DevCouncil.git
|
|
32
|
+
cd DevCouncil
|
|
33
|
+
bash scripts/build-week-demo.sh
|
|
34
|
+
|
|
35
|
+
# Interactive graph artifact (self-contained HTML)
|
|
36
|
+
mkdir -p /tmp/devcouncil-judge-demo
|
|
37
|
+
dev graph demo --project-root /tmp/devcouncil-judge-demo --json
|
|
38
|
+
# Open /tmp/devcouncil-judge-demo/.devcouncil/graph/demo.html
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Until `0.4.1` is on the registry, clone this repository and run the same commands from a checkout that includes the Build Week fixes (`uv sync --group dev` if you need local `dev`).
|
|
42
|
+
|
|
43
|
+
### Eligible Build Week work
|
|
44
|
+
|
|
45
|
+
DevCouncil existed before OpenAI Build Week. This submission covers only meaningful extensions on or after **July 13, 2026**. Eligible history begins at commit `6f5bd73` (baseline before eligible work: `3cfd5d1`). Major themes:
|
|
46
|
+
|
|
47
|
+
- canonical SQLite code-intelligence index, multi-language grammars, incremental watching, graph queries/community detection, and a self-contained interactive code-graph artifact (including a ForceGraph compatibility fix for the packed demo);
|
|
48
|
+
- stronger deterministic verification: stop gates, claim checking, diff-to-evidence coverage, task leases, PDG/corpus checks, bounded repair, machine-readable next actions;
|
|
49
|
+
- deeper CLI, MCP, dashboard, coding-agent, and CI integration, plus the installable npm release path aimed at `0.4.1` for judges.
|
|
50
|
+
|
|
51
|
+
During Build Week, Codex and GPT-5.6 were used as an engineering partner to map paths, challenge claims, run install/browser checks, implement focused repairs, and verify behavior. The maintainer set requirements, scope, and acceptance evidence. The primary Codex `/feedback` session ID is on the Devpost submission. This does **not** claim that all eligible code was authored exclusively by Codex/GPT-5.6.
|
|
52
|
+
|
|
53
|
+
See [docs/build-week-demo.md](docs/build-week-demo.md) for the provider-free demo walkthrough.
|
|
16
54
|
|
|
17
55
|
## Documentation
|
|
18
56
|
|
|
57
|
+
- [Build Week demo](docs/build-week-demo.md): provider-free red-to-green judge script.
|
|
19
58
|
- [Quickstart](docs/quickstart.md): shortest install-to-first-task path.
|
|
20
59
|
- [Daily workflow](docs/workflow.md): manual sidecar loop, verification, repair, rollback, and `dev watch`.
|
|
21
60
|
- [Coding CLI integration](docs/coding-cli-integration.md): tiers, Claude Code, Codex, OpenCode, Antigravity, Cursor, Grok Build, Aider, MCP, hooks, stop gate / claim checks, and automated executors (Gemini deprecated).
|
|
22
61
|
- [CLI command reference](docs/cli-reference.md): available `dev` commands.
|
|
23
|
-
- [Repo map & code graph](docs/code-graph.md): `dev map` / `dev graph`
|
|
62
|
+
- [Repo map & code graph](docs/code-graph.md): `dev map` / `dev graph` -- navigation, dead code, blast radius, PDG (opt-in), HTML visualizer.
|
|
24
63
|
- [Corpus side index](docs/corpus.md): `dev corpus` for docs/PDFs/images and optional verify gates.
|
|
25
64
|
- [Hero loop](docs/hero-loop.md): certified Claude Code + MCP closed loop, leases, rigor, and `dev check --verify` on-ramp.
|
|
26
65
|
- [Architecture](docs/architecture.md): components, artifact graph, state machine, gating policy, and gated execution.
|
|
@@ -152,7 +191,11 @@ DevCouncil is an application layer around coding agents. It does not just emit p
|
|
|
152
191
|
### Workflow Features
|
|
153
192
|
|
|
154
193
|
- **Repository onboarding:** `dev setup` initializes `.devcouncil/`, generates the repo map + `AGENTS.md`/`CLAUDE.md` guides, scaffolds applicable engineering skills, runs environment checks, offers integration setup, and prints the next useful commands. Use `--skip-map` / `--skip-skills` to opt out, or `--scaffold-ci` to also write a starter GitHub Actions workflow. **`dev boot "goal"`** chains setup, `dev integrate --apply`, optional CI scaffold flags, and `dev go` in one command (see [quickstart](docs/quickstart.md)).
|
|
155
|
-
- **Repository mapping:** `dev map` writes `.devcouncil/repo_map.json` and a symbol-level `.devcouncil/graph/code_graph.json`, identifies important files and subsystems, filters generated/temp files, and keeps managed `AGENTS.md` / `CLAUDE.md` workspace guides synchronized. Subsystems, entry points, neighbors, and important surfaces are inferred generically for **any** repository — grouped from the directory tree and ranked by an import-graph in-degree. Freshness uses git HEAD, tracked-file hash, and a content fingerprint so plain edits mark the map stale; a **missing map is stale** (fail-closed on hard rigor). Post-tool-use hooks and `dev map --watch` refresh incrementally. Unified analyze entry: `dev graph ingest`. Query with `dev graph query|trace|dead|search|cypher|html`. Liveness lists are capped at 5000 per list and 256 dependents per file (truncation metadata when hit). The map is also generated automatically on first init.
|
|
194
|
+
- **Repository mapping:** `dev map` writes `.devcouncil/repo_map.json` and a symbol-level `.devcouncil/graph/code_graph.json`, identifies important files and subsystems, filters generated/temp files, and keeps managed `AGENTS.md` / `CLAUDE.md` workspace guides synchronized. Subsystems, entry points, neighbors, and important surfaces are inferred generically for **any** repository — grouped from the directory tree and ranked by an import-graph in-degree. Freshness uses git HEAD, tracked-file hash, and a content fingerprint so plain edits mark the map stale; a **missing map is stale** (fail-closed on hard rigor). Post-tool-use hooks and `dev map --watch` refresh incrementally. Unified analyze entry: `dev graph ingest`. Query with `dev graph query|trace|dead|search|cypher|html`. Liveness lists are capped at 5000 per list and 256 dependents per file (truncation metadata when hit). The map is also generated automatically on first init. (See [docs/code-graph.md](docs/code-graph.md) for details).
|
|
195
|
+
|
|
196
|
+
<p align="center">
|
|
197
|
+
<img src="docs/graph_preview.png" alt="Repository Code Graph Preview" width="600">
|
|
198
|
+
</p>
|
|
156
199
|
- **Engineering skills:** `dev skills` lists the bundled skills and shows which apply to the repository; `dev skills scaffold` writes them into `.claude/skills/<name>/SKILL.md`. A merged always-on `core-engineering` skill (think-before-coding, simplicity, surgical changes, goal-driven execution, evidence-grounded communication) plus domain skills (Android, iOS, Windows, web, AI training) that brief the agent on current SDKs, deprecations, and tooling before coding. Applicable skills are also embedded into `dev prompt` output.
|
|
157
200
|
- **CI scaffolding:** `dev scaffold-ci` writes a starter `.github/workflows/devcouncil.yml` derived from the configured test/lint/typecheck commands, filtered to the detected language stack; it never overwrites existing CI unless `--force`. Run `dev scaffold-ci --evidence` to generate `.github/workflows/devcouncil-evidence.yml` which automates verifying PRs and uploading evidence JSON and HTML reports.
|
|
158
201
|
- **Planning council:** `dev plan` turns a goal into requirements, acceptance criteria, assumptions, critique findings, and executable tasks. When advisory gaps remain, the project stays in `AWAITING_USER_DECISIONS` until you run `dev approve` (or `dev e2e`/`dev go --force`).
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -210,23 +210,20 @@ def status(
|
|
|
210
210
|
|
|
211
211
|
if payload["pending_signal_items"]:
|
|
212
212
|
table = Table(title="Pending Agent Responses")
|
|
213
|
-
table.add_column("
|
|
213
|
+
table.add_column("ID", style="cyan")
|
|
214
|
+
table.add_column("Client")
|
|
214
215
|
table.add_column("Task")
|
|
215
|
-
table.add_column("Transcript", overflow="fold")
|
|
216
|
-
table.add_column("Review Command", overflow="fold")
|
|
217
216
|
for signal in payload["pending_signal_items"]:
|
|
218
217
|
table.add_row(
|
|
218
|
+
signal.get("id") or "",
|
|
219
219
|
signal.get("client") or "",
|
|
220
220
|
signal.get("task_id") or "(unscoped)",
|
|
221
|
-
signal.get("transcript_path") or "",
|
|
222
|
-
signal.get("review_command") or "",
|
|
223
221
|
)
|
|
224
222
|
console.print(table)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
console.print(command)
|
|
223
|
+
console.print(
|
|
224
|
+
"[dim]Sensitive transcript paths and review commands: "
|
|
225
|
+
"`dev watch signals` / `dev watch review`.[/dim]"
|
|
226
|
+
)
|
|
230
227
|
|
|
231
228
|
|
|
232
229
|
@app.command("resolve")
|
|
@@ -47,7 +47,7 @@ def _vendor_js() -> str:
|
|
|
47
47
|
"nodeVal:function(){return this},linkColor:function(){return this},"
|
|
48
48
|
"linkDirectionalParticles:function(){return this},"
|
|
49
49
|
"linkDirectionalParticleWidth:function(){return this},"
|
|
50
|
-
"onNodeClick:function(){return this},
|
|
50
|
+
"onNodeClick:function(){return this},"
|
|
51
51
|
"width:function(){return this},height:function(){return this},_missing:true};};"
|
|
52
52
|
)
|
|
53
53
|
|
|
@@ -565,7 +565,16 @@ const g = Graph(elem)
|
|
|
565
565
|
if (pathHighlight.size && pathHighlight.has(s) && pathHighlight.has(t)) return 3;
|
|
566
566
|
return 1.5;
|
|
567
567
|
}})
|
|
568
|
-
.onNodeClick(n => {{
|
|
568
|
+
.onNodeClick((n, event) => {{
|
|
569
|
+
// ForceGraph exposes click events but no double-click chain method.
|
|
570
|
+
// The native event detail increments for a double click, so use it to
|
|
571
|
+
// preserve neighbor expansion without depending on a nonexistent API.
|
|
572
|
+
if (event && event.detail >= 2) {{
|
|
573
|
+
const links = activePayload().links;
|
|
574
|
+
expandIds = neighborExpand(n.id, links, 1);
|
|
575
|
+
redraw();
|
|
576
|
+
return;
|
|
577
|
+
}}
|
|
569
578
|
showDetail(n);
|
|
570
579
|
if (selected.length === 1 && selected[0] === n.id) {{ selected = []; pathHighlight = new Set(); redraw(); return; }}
|
|
571
580
|
if (selected.length >= 2) selected = [];
|
|
@@ -577,11 +586,6 @@ const g = Graph(elem)
|
|
|
577
586
|
showDetail(n);
|
|
578
587
|
}}
|
|
579
588
|
redraw();
|
|
580
|
-
}})
|
|
581
|
-
.onNodeDblClick(n => {{
|
|
582
|
-
const links = activePayload().links;
|
|
583
|
-
expandIds = neighborExpand(n.id, links, 1);
|
|
584
|
-
redraw();
|
|
585
589
|
}});
|
|
586
590
|
|
|
587
591
|
function redraw() {{
|
|
@@ -38,6 +38,6 @@ async def handle_cli(root: Path, arguments: dict) -> list[TextContent]:
|
|
|
38
38
|
if forbidden:
|
|
39
39
|
return error_text("forbidden flag(s) through MCP: " + ", ".join(forbidden), code="forbidden_flags", flags=forbidden)
|
|
40
40
|
try:
|
|
41
|
-
return json_text(run_cli_command(args, root))
|
|
41
|
+
return json_text(run_cli_command(args, root, truncate=True))
|
|
42
42
|
except Exception as exc:
|
|
43
43
|
return error_text(str(exc), code="cli_execution_error")
|
|
@@ -20,12 +20,139 @@ from devcouncil.integrations.mcp.util import (
|
|
|
20
20
|
from devcouncil.storage.db import Database
|
|
21
21
|
from devcouncil.storage.repositories import TaskRepository
|
|
22
22
|
|
|
23
|
+
_BINARY_PROBE_BYTES = 8192
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
27
|
+
return subprocess.run(
|
|
28
|
+
args,
|
|
29
|
+
cwd=root,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
encoding="utf-8",
|
|
33
|
+
errors="replace",
|
|
34
|
+
timeout=CLI_TIMEOUT_SECONDS,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_name_status_z(data: str) -> dict[str, str]:
|
|
39
|
+
"""Parse ``git diff --name-status -z`` into ``{path: status}``.
|
|
40
|
+
|
|
41
|
+
Rename/copy records are ``STATUS\\0OLD\\0NEW\\0``; other records are
|
|
42
|
+
``STATUS\\0PATH\\0``. The new path is the authoritative key for renames.
|
|
43
|
+
"""
|
|
44
|
+
status_by_path: dict[str, str] = {}
|
|
45
|
+
parts = data.split("\0")
|
|
46
|
+
i = 0
|
|
47
|
+
while i < len(parts):
|
|
48
|
+
status = parts[i]
|
|
49
|
+
if not status:
|
|
50
|
+
i += 1
|
|
51
|
+
continue
|
|
52
|
+
kind = status[0]
|
|
53
|
+
if kind in "RC" and i + 2 < len(parts):
|
|
54
|
+
new_path = parts[i + 2].replace("\\", "/")
|
|
55
|
+
if new_path:
|
|
56
|
+
status_by_path[new_path] = status
|
|
57
|
+
i += 3
|
|
58
|
+
continue
|
|
59
|
+
if i + 1 < len(parts):
|
|
60
|
+
path = parts[i + 1].replace("\\", "/")
|
|
61
|
+
if path:
|
|
62
|
+
status_by_path[path] = status
|
|
63
|
+
i += 2
|
|
64
|
+
continue
|
|
65
|
+
break
|
|
66
|
+
return status_by_path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_untracked_file_diff(rel_path: str, full_path: Path) -> tuple[str, int]:
|
|
70
|
+
"""Return ``(unified_diff_fragment, addition_count)`` for a new untracked file."""
|
|
71
|
+
try:
|
|
72
|
+
raw = full_path.read_bytes()
|
|
73
|
+
except OSError:
|
|
74
|
+
return "", 0
|
|
75
|
+
|
|
76
|
+
header = [
|
|
77
|
+
f"diff --git a/{rel_path} b/{rel_path}",
|
|
78
|
+
"new file mode 100644",
|
|
79
|
+
"--- /dev/null",
|
|
80
|
+
f"+++ b/{rel_path}",
|
|
81
|
+
]
|
|
82
|
+
if b"\0" in raw[:_BINARY_PROBE_BYTES]:
|
|
83
|
+
return "\n".join([*header, f"Binary files /dev/null and b/{rel_path} differ"]), 0
|
|
84
|
+
|
|
85
|
+
text = raw.decode("utf-8", errors="replace")
|
|
86
|
+
if not text:
|
|
87
|
+
return "\n".join(header) + "\n", 0
|
|
88
|
+
|
|
89
|
+
lines = text.splitlines()
|
|
90
|
+
if text.endswith(("\n", "\r")):
|
|
91
|
+
line_count = len(lines)
|
|
92
|
+
else:
|
|
93
|
+
line_count = max(len(lines), 1)
|
|
94
|
+
|
|
95
|
+
diff_lines = [*header, f"@@ -0,0 +1,{line_count} @@"]
|
|
96
|
+
diff_lines.extend(f"+{line}" for line in lines)
|
|
97
|
+
return "\n".join(diff_lines), line_count
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _list_untracked(root: Path, paths: list[str]) -> tuple[list[str], str | None]:
|
|
101
|
+
"""List untracked paths; return ``(paths, error)`` when the Git call fails."""
|
|
102
|
+
args = ["git", "ls-files", "--others", "--exclude-standard", "-z"]
|
|
103
|
+
if paths:
|
|
104
|
+
args.append("--")
|
|
105
|
+
args.extend(paths)
|
|
106
|
+
try:
|
|
107
|
+
proc = _run_git(root, args)
|
|
108
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
109
|
+
return [], str(exc)
|
|
110
|
+
if proc.returncode != 0:
|
|
111
|
+
detail = (proc.stderr or proc.stdout or f"git ls-files exited {proc.returncode}").strip()
|
|
112
|
+
return [], detail or f"git ls-files exited {proc.returncode}"
|
|
113
|
+
return [p.replace("\\", "/") for p in proc.stdout.split("\0") if p.strip()], None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _collect_untracked(
|
|
117
|
+
root: Path,
|
|
118
|
+
paths: list[str],
|
|
119
|
+
*,
|
|
120
|
+
known_paths: set[str],
|
|
121
|
+
) -> tuple[list[dict[str, object]], str, str | None]:
|
|
122
|
+
"""Build file entries and unified diff for untracked files in scope."""
|
|
123
|
+
untracked, err = _list_untracked(root, paths)
|
|
124
|
+
if err is not None:
|
|
125
|
+
return [], "", err
|
|
126
|
+
|
|
127
|
+
files: list[dict[str, object]] = []
|
|
128
|
+
fragments: list[str] = []
|
|
129
|
+
for rel in untracked:
|
|
130
|
+
if rel in known_paths:
|
|
131
|
+
continue
|
|
132
|
+
full = root / rel
|
|
133
|
+
if not full.is_file():
|
|
134
|
+
continue
|
|
135
|
+
fragment, additions = _format_untracked_file_diff(rel, full)
|
|
136
|
+
if not fragment:
|
|
137
|
+
continue
|
|
138
|
+
files.append({
|
|
139
|
+
"path": rel,
|
|
140
|
+
"status": "A",
|
|
141
|
+
"additions": additions,
|
|
142
|
+
"deletions": 0,
|
|
143
|
+
})
|
|
144
|
+
fragments.append(fragment.rstrip("\n"))
|
|
145
|
+
unified = "\n".join(fragments)
|
|
146
|
+
if unified:
|
|
147
|
+
unified += "\n"
|
|
148
|
+
return files, unified, None
|
|
149
|
+
|
|
23
150
|
|
|
24
151
|
async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, object]:
|
|
25
152
|
"""Compute a (optionally path-scoped, optionally staged) git diff."""
|
|
26
153
|
diff_args = ["git", "diff"]
|
|
27
154
|
numstat_args = ["git", "diff", "--numstat"]
|
|
28
|
-
namestatus_args = ["git", "diff", "--name-status"]
|
|
155
|
+
namestatus_args = ["git", "diff", "--name-status", "-z"]
|
|
29
156
|
if staged:
|
|
30
157
|
for args in (diff_args, numstat_args, namestatus_args):
|
|
31
158
|
args.append("--cached")
|
|
@@ -35,10 +162,7 @@ async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, obje
|
|
|
35
162
|
args.extend(paths)
|
|
36
163
|
|
|
37
164
|
def _run(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
38
|
-
return
|
|
39
|
-
args, cwd=root, capture_output=True, text=True,
|
|
40
|
-
encoding="utf-8", errors="replace", timeout=CLI_TIMEOUT_SECONDS,
|
|
41
|
-
)
|
|
165
|
+
return _run_git(root, args)
|
|
42
166
|
|
|
43
167
|
try:
|
|
44
168
|
loop = asyncio.get_event_loop()
|
|
@@ -50,11 +174,23 @@ async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, obje
|
|
|
50
174
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
51
175
|
return {"ok": False, "files": [], "unified_diff": "", "truncated": False, "error": str(exc)}
|
|
52
176
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
177
|
+
for proc, label in (
|
|
178
|
+
(diff_proc, "git diff"),
|
|
179
|
+
(numstat_proc, "git diff --numstat"),
|
|
180
|
+
(namestatus_proc, "git diff --name-status"),
|
|
181
|
+
):
|
|
182
|
+
if proc.returncode != 0:
|
|
183
|
+
detail = (proc.stderr or proc.stdout or f"{label} exited {proc.returncode}").strip()
|
|
184
|
+
return {
|
|
185
|
+
"ok": False,
|
|
186
|
+
"files": [],
|
|
187
|
+
"unified_diff": "",
|
|
188
|
+
"truncated": False,
|
|
189
|
+
"error": detail or f"{label} exited {proc.returncode}",
|
|
190
|
+
"staged": staged,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
status_by_path = _parse_name_status_z(namestatus_proc.stdout)
|
|
58
194
|
|
|
59
195
|
files: list[dict[str, object]] = []
|
|
60
196
|
for line in numstat_proc.stdout.splitlines():
|
|
@@ -62,6 +198,9 @@ async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, obje
|
|
|
62
198
|
if len(parts) < 3:
|
|
63
199
|
continue
|
|
64
200
|
added_str, deleted_str, file_path = parts[0], parts[1], parts[-1]
|
|
201
|
+
# Rename numstat without -z may use "old => new"; prefer the new side.
|
|
202
|
+
if " => " in file_path:
|
|
203
|
+
file_path = file_path.split(" => ", 1)[-1]
|
|
65
204
|
file_path = file_path.replace("\\", "/")
|
|
66
205
|
files.append({
|
|
67
206
|
"path": file_path,
|
|
@@ -70,10 +209,37 @@ async def git_diff(root: Path, paths: list[str], staged: bool) -> dict[str, obje
|
|
|
70
209
|
"deletions": int(deleted_str) if deleted_str.isdigit() else 0,
|
|
71
210
|
})
|
|
72
211
|
|
|
73
|
-
|
|
212
|
+
unified_parts = [diff_proc.stdout.rstrip("\n")] if diff_proc.stdout else []
|
|
213
|
+
if not staged:
|
|
214
|
+
known = {str(entry["path"]) for entry in files}
|
|
215
|
+
untracked_files, untracked_diff, untracked_err = await asyncio.get_event_loop().run_in_executor(
|
|
216
|
+
None,
|
|
217
|
+
lambda: _collect_untracked(root, paths, known_paths=known),
|
|
218
|
+
)
|
|
219
|
+
if untracked_err is not None:
|
|
220
|
+
return {
|
|
221
|
+
"ok": False,
|
|
222
|
+
"files": [],
|
|
223
|
+
"unified_diff": "",
|
|
224
|
+
"truncated": False,
|
|
225
|
+
"error": untracked_err,
|
|
226
|
+
"staged": staged,
|
|
227
|
+
}
|
|
228
|
+
files.extend(untracked_files)
|
|
229
|
+
if untracked_diff:
|
|
230
|
+
unified_parts.append(untracked_diff.rstrip("\n"))
|
|
231
|
+
|
|
232
|
+
combined = "\n".join(part for part in unified_parts if part)
|
|
233
|
+
if combined:
|
|
234
|
+
combined += "\n"
|
|
235
|
+
unified_diff, truncated = truncate_text(combined)
|
|
74
236
|
return {"ok": True, "files": files, "unified_diff": unified_diff, "truncated": truncated, "staged": staged}
|
|
75
237
|
|
|
76
238
|
|
|
239
|
+
def _empty_diff_payload(staged: bool) -> dict[str, object]:
|
|
240
|
+
return {"ok": True, "files": [], "unified_diff": "", "truncated": False, "staged": staged}
|
|
241
|
+
|
|
242
|
+
|
|
77
243
|
async def handle_get_diff(root: Path, db: Database | None, arguments: dict) -> list[TextContent]:
|
|
78
244
|
if not is_git_repo(root):
|
|
79
245
|
return error_text("get_diff requires a git repository.", code="not_a_git_repo")
|
|
@@ -86,14 +252,30 @@ async def handle_get_diff(root: Path, db: Database | None, arguments: dict) -> l
|
|
|
86
252
|
staged_value = arguments.get("staged", False)
|
|
87
253
|
if not isinstance(staged_value, bool):
|
|
88
254
|
return error_text("staged must be a boolean", code="invalid_arguments", argument="staged")
|
|
89
|
-
|
|
90
|
-
|
|
255
|
+
|
|
256
|
+
scope_paths: list[str] = [p.replace("\\", "/") for p in explicit_paths]
|
|
257
|
+
task_scoped = False
|
|
258
|
+
if task_id:
|
|
259
|
+
if db is None:
|
|
260
|
+
return error_text(
|
|
261
|
+
"DevCouncil not initialized in this directory.",
|
|
262
|
+
code="not_initialized",
|
|
263
|
+
)
|
|
91
264
|
with db.get_session() as session:
|
|
92
265
|
task = TaskRepository(session).get_by_id(task_id)
|
|
93
266
|
if task is None:
|
|
94
267
|
return error_text(f"Task {task_id} not found.", code="not_found", task_id=task_id)
|
|
95
|
-
for
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
268
|
+
planned = [pf.path.replace("\\", "/") for pf in task.planned_files]
|
|
269
|
+
planned_set = set(planned)
|
|
270
|
+
task_scoped = True
|
|
271
|
+
if explicit_paths:
|
|
272
|
+
# Intersect only — explicit paths must never broaden task scope.
|
|
273
|
+
scope_paths = [p for p in scope_paths if p in planned_set]
|
|
274
|
+
else:
|
|
275
|
+
scope_paths = planned
|
|
276
|
+
|
|
277
|
+
if task_scoped and not scope_paths:
|
|
278
|
+
# Empty planned scope or empty intersection: fail closed to empty, never full repo.
|
|
279
|
+
return json_text(_empty_diff_payload(staged_value))
|
|
280
|
+
|
|
99
281
|
return json_text(await git_diff(root, scope_paths, staged_value))
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from mcp.types import TextContent
|
|
9
8
|
|
|
10
9
|
from devcouncil.integrations.mcp.util import (
|
|
11
10
|
json_text,
|
|
11
|
+
parse_cli_json,
|
|
12
12
|
run_cli_command,
|
|
13
13
|
)
|
|
14
14
|
|
|
@@ -21,13 +21,11 @@ async def handle_graph_context(root: Path, arguments: dict) -> list[TextContent]
|
|
|
21
21
|
for item in files:
|
|
22
22
|
if isinstance(item, str) and item:
|
|
23
23
|
file_args.extend(["--file", item])
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
except json.JSONDecodeError:
|
|
30
|
-
pass
|
|
24
|
+
payload, _cli_error = parse_cli_json(
|
|
25
|
+
run_cli_command(["graph-context", "--json", *file_args], root, truncate=False),
|
|
26
|
+
)
|
|
27
|
+
if payload is not None:
|
|
28
|
+
return json_text(payload)
|
|
31
29
|
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
32
30
|
|
|
33
31
|
context = CodeReviewGraphAdapter(root).get_context(
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from mcp.types import TextContent
|
|
9
8
|
|
|
10
9
|
from devcouncil.integrations.mcp.util import (
|
|
11
10
|
json_text,
|
|
11
|
+
parse_cli_json,
|
|
12
12
|
required_string_argument,
|
|
13
13
|
run_cli_command,
|
|
14
14
|
)
|
|
@@ -20,11 +20,9 @@ async def handle_select_knowledge(root: Path, arguments: dict) -> list[TextConte
|
|
|
20
20
|
if arg_error:
|
|
21
21
|
return arg_error
|
|
22
22
|
assert goal is not None
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
except json.JSONDecodeError:
|
|
29
|
-
pass
|
|
23
|
+
payload, _cli_error = parse_cli_json(
|
|
24
|
+
run_cli_command(["okf", "select", "--json", "--goal", goal], root, truncate=False),
|
|
25
|
+
)
|
|
26
|
+
if payload is not None:
|
|
27
|
+
return json_text(payload)
|
|
30
28
|
return json_text(select_knowledge_payload(root, goal))
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from mcp.types import Resource, TextContent
|
|
9
8
|
from pydantic import AnyUrl
|
|
10
9
|
|
|
11
|
-
from devcouncil.integrations.mcp.util import
|
|
10
|
+
from devcouncil.integrations.mcp.util import (
|
|
11
|
+
json_text,
|
|
12
|
+
parse_cli_json,
|
|
13
|
+
required_string_argument,
|
|
14
|
+
run_cli_command,
|
|
15
|
+
run_cli_json,
|
|
16
|
+
)
|
|
12
17
|
from devcouncil.knowledge.resource_discovery import (
|
|
13
18
|
discover_knowledge_sources,
|
|
14
19
|
knowledge_source_uri,
|
|
@@ -27,28 +32,12 @@ __all__ = [
|
|
|
27
32
|
from devcouncil.knowledge.resource_discovery import knowledge_settings # noqa: E402
|
|
28
33
|
|
|
29
34
|
|
|
30
|
-
def _cli_json(root: Path, args: list[str]) -> tuple[dict | None, list[TextContent] | None]:
|
|
31
|
-
result = run_cli_command(args, root)
|
|
32
|
-
stdout = str(result.get("stdout") or "").strip()
|
|
33
|
-
if stdout:
|
|
34
|
-
try:
|
|
35
|
-
return json.loads(stdout), None
|
|
36
|
-
except json.JSONDecodeError:
|
|
37
|
-
pass
|
|
38
|
-
if not result.get("ok"):
|
|
39
|
-
stderr = str(result.get("stderr") or "CLI command failed")
|
|
40
|
-
from devcouncil.integrations.mcp.util import error_text
|
|
41
|
-
return None, error_text(stderr, code="cli_failed")
|
|
42
|
-
from devcouncil.integrations.mcp.util import error_text
|
|
43
|
-
return None, error_text("CLI command returned invalid JSON", code="cli_parse_error")
|
|
44
|
-
|
|
45
|
-
|
|
46
35
|
async def handle_get_task_provenance(root: Path, db: object, arguments: dict) -> list[TextContent]:
|
|
47
36
|
task_id, arg_error = required_string_argument(arguments, "task_id")
|
|
48
37
|
if arg_error:
|
|
49
38
|
return arg_error
|
|
50
39
|
assert task_id is not None
|
|
51
|
-
payload, cli_error =
|
|
40
|
+
payload, cli_error = run_cli_json(["provenance", task_id, "--json"], root)
|
|
52
41
|
if cli_error:
|
|
53
42
|
return cli_error
|
|
54
43
|
assert payload is not None
|
|
@@ -57,25 +46,22 @@ async def handle_get_task_provenance(root: Path, db: object, arguments: dict) ->
|
|
|
57
46
|
|
|
58
47
|
async def list_resources(root: Path) -> list[Resource]:
|
|
59
48
|
"""Expose the DevCouncil corpus as browsable MCP resources."""
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
]
|
|
77
|
-
except json.JSONDecodeError:
|
|
78
|
-
pass
|
|
49
|
+
payload, _cli_error = parse_cli_json(
|
|
50
|
+
run_cli_command(["resource", "list", "--json"], root, truncate=False),
|
|
51
|
+
)
|
|
52
|
+
if payload is not None:
|
|
53
|
+
descriptors = payload.get("resources") or []
|
|
54
|
+
if isinstance(descriptors, list):
|
|
55
|
+
return [
|
|
56
|
+
Resource(
|
|
57
|
+
uri=AnyUrl(item["uri"]),
|
|
58
|
+
name=item["name"],
|
|
59
|
+
description=item["description"],
|
|
60
|
+
mimeType=item["mimeType"],
|
|
61
|
+
)
|
|
62
|
+
for item in descriptors
|
|
63
|
+
if isinstance(item, dict) and item.get("uri")
|
|
64
|
+
]
|
|
79
65
|
# Fallback when CLI is unavailable (e.g. during early init).
|
|
80
66
|
from devcouncil.reporting.mcp_resources import list_mcp_resource_uris
|
|
81
67
|
|
|
@@ -92,7 +78,7 @@ async def list_resources(root: Path) -> list[Resource]:
|
|
|
92
78
|
|
|
93
79
|
async def read_resource(root: Path, uri: AnyUrl) -> str:
|
|
94
80
|
key = str(uri).rstrip("/")
|
|
95
|
-
result = run_cli_command(["resource", "read", key], root)
|
|
81
|
+
result = run_cli_command(["resource", "read", key], root, truncate=False)
|
|
96
82
|
stdout = result.get("stdout")
|
|
97
83
|
if result.get("ok") and stdout is not None:
|
|
98
84
|
return str(stdout)
|