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 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/devcouncil_logo_premium.png" alt="DevCouncil Logo" width="300">
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](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
8
8
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
9
9
  [![uv](https://img.shields.io/badge/managed%20by-uv-purple.svg)](https://github.com/astral-sh/uv)
10
10
 
11
- **"DevCouncil should not merely generate code. It should make AI-generated work prove that it satisfied the original intent."**
11
+ ## OpenAI Build Week 2026
12
12
 
13
- DevCouncil is a high-integrity command-line orchestration platform for AI-assisted software development. It turns AI implementation from a black-box generation task into a gated engineering workflow where every change is authorized, verified, and traceable back to a requirement.
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
- DevCouncil does not replace coding agents. Its primary native path is Claude Code, followed by Codex CLI; it also works beside OpenCode, Google Antigravity CLI, Warp/Oz, Cursor, Aider, and bring-your-own prompt-taking CLIs. It owns the plan, task scope, verification loop, repair prompts, and evidence trail.
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` navigation, dead code, blast radius, PDG (opt-in), HTML visualizer.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devcouncil",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Gated orchestrator for AI-assisted software development",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/bharathvbcr/DevCouncil#readme",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devcouncil"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Gated orchestrator for AI-assisted software development"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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("Client", style="cyan")
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
- commands = [signal.get("review_command") for signal in payload["pending_signal_items"] if signal.get("review_command")]
226
- if commands:
227
- console.print("[cyan]Pending review commands:[/cyan]")
228
- for command in commands:
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},onNodeDblClick: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 subprocess.run(
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
- status_by_path: dict[str, str] = {}
54
- for line in namestatus_proc.stdout.splitlines():
55
- parts = line.split("\t")
56
- if len(parts) >= 2:
57
- status_by_path[parts[-1].replace("\\", "/")] = parts[0]
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
- unified_diff, truncated = truncate_text(diff_proc.stdout)
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
- scope_paths: list[str] = list(explicit_paths)
90
- if task_id and db:
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 planned in task.planned_files:
96
- p = planned.path.replace("\\", "/")
97
- if p not in scope_paths:
98
- scope_paths.append(p)
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
- result = run_cli_command(["graph-context", "--json", *file_args], root)
25
- if result.get("ok"):
26
- try:
27
- payload = json.loads(str(result.get("stdout") or "{}"))
28
- return json_text(payload)
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
- result = run_cli_command(["okf", "select", "--json", "--goal", goal], root)
24
- if result.get("ok"):
25
- try:
26
- payload = json.loads(str(result.get("stdout") or "{}"))
27
- return json_text(payload)
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 json_text, required_string_argument, run_cli_command
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 = _cli_json(root, ["provenance", task_id, "--json"])
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
- result = run_cli_command(["resource", "list", "--json"], root)
61
- stdout = str(result.get("stdout") or "").strip()
62
- if stdout:
63
- try:
64
- payload = json.loads(stdout)
65
- descriptors = payload.get("resources") or []
66
- if isinstance(descriptors, list):
67
- return [
68
- Resource(
69
- uri=AnyUrl(item["uri"]),
70
- name=item["name"],
71
- description=item["description"],
72
- mimeType=item["mimeType"],
73
- )
74
- for item in descriptors
75
- if isinstance(item, dict) and item.get("uri")
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)