cowork-flow 1.0.0 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +11 -2
  3. package/package.json +1 -1
  4. package/presets/dsh/plugins/workflow-state.js +25 -15
  5. package/src/cli.js +6 -0
  6. package/src/commands/mcp-state.js +66 -0
  7. package/template/.claude/hooks/inject-workflow-state.py +6 -5
  8. package/template/.codex/hooks/inject-workflow-state.py +9 -1
  9. package/template/.cowork-flow/.version +1 -1
  10. package/template/.cowork-flow/adapters/claude-code/adapter.yaml +1 -0
  11. package/template/.cowork-flow/adapters/codex/adapter.yaml +1 -0
  12. package/template/.cowork-flow/adapters/dsh/adapter.yaml +1 -0
  13. package/template/.cowork-flow/adapters/opencode/adapter.yaml +1 -0
  14. package/template/.cowork-flow/adapters/zcode/adapter.yaml +1 -0
  15. package/template/.cowork-flow/scripts/adapters/cli/state.py +121 -0
  16. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +28 -0
  17. package/template/.cowork-flow/scripts/adapters/cli/task.py +6 -4
  18. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +5 -1
  19. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +9 -0
  20. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +208 -28
  21. package/template/.cowork-flow/scripts/adapters/mcp/_bootstrap.py +14 -0
  22. package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +305 -0
  23. package/template/.cowork-flow/scripts/infra/git_snapshot.py +49 -10
  24. package/template/.cowork-flow/scripts/infra/skill_manifest.py +3 -1
  25. package/template/.cowork-flow/scripts/run.py +3 -0
  26. package/template/.cowork-flow/scripts/services/context_discovery.py +26 -0
  27. package/template/.cowork-flow/scripts/services/context_paths.py +79 -9
  28. package/template/.cowork-flow/scripts/services/fact_view.py +439 -0
  29. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +34 -3
  30. package/template/.cowork-flow/scripts/services/task_context.py +2 -21
  31. package/template/.cowork-flow/scripts/services/task_lifecycle.py +202 -3
  32. package/template/.cowork-flow/scripts/services/workflow_runtime.py +45 -0
  33. package/template/.cowork-flow/spec/contracts/context-injection.md +208 -0
  34. package/template/.cowork-flow/spec/contracts/decision-anchor.md +7 -0
  35. package/template/.cowork-flow/spec/contracts/fact-layer-access.md +50 -0
  36. package/template/.cowork-flow/spec/runtime/host-assets.json +10 -5
  37. package/template/.cowork-flow/spec/runtime/scope-rules.json +16 -0
  38. package/template/.opencode/plugins/cowork-flow.js +338 -11
  39. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  40. package/template/.zcode/hooks/hooks.json +1 -1
  41. package/template/.zcode/hooks/inject-context.js +418 -22
  42. package/template/.zcode/hooks/inject-context.selfcheck.mjs +277 -23
@@ -140,6 +140,15 @@ def _add_next_runtime_inputs(parser: argparse.ArgumentParser) -> None:
140
140
  action="store_true",
141
141
  help="Auto git commit after archive_task",
142
142
  )
143
+ parser.add_argument(
144
+ "--executor",
145
+ help="Explicit executor identity; enables sessionless (CI) start_task",
146
+ )
147
+ parser.add_argument(
148
+ "--takeover",
149
+ action="store_true",
150
+ help="Take the task over from its current executor on start_task",
151
+ )
143
152
 
144
153
 
145
154
  def _add_next_command(subparsers: argparse._SubParsersAction) -> None:
@@ -63,6 +63,7 @@ def build_hook_context(
63
63
  host: str,
64
64
  adapter: str,
65
65
  preamble: tuple[str, ...],
66
+ session_start: bool | None = None,
66
67
  ) -> str:
67
68
  breadcrumbs = _load_breadcrumbs(root)
68
69
  runtime_context, runtime_context_id = _resolve_runtime_context(
@@ -88,7 +89,6 @@ def build_hook_context(
88
89
  source = f"runtime-context-invalid:{runtime_context_id}"
89
90
  extra_lines = [
90
91
  f"Runtime context: {runtime_context_id}",
91
- "Scope: subagent",
92
92
  (
93
93
  "Runtime context is missing, closed, or invalid. "
94
94
  "Do not run standalone lifecycle commands, resume, archive, commit, or spawn."
@@ -103,17 +103,187 @@ def build_hook_context(
103
103
  )
104
104
  if extra_lines:
105
105
  body = "\n".join([body, *extra_lines])
106
- if task_path is None:
107
- header = f"Status: {status}\nSource: {source}"
106
+ if session_start is None:
107
+ # No event signal from the host: treat the first injection as a
108
+ # session start. Session state files appear once a task activation
109
+ # exists (start), so their absence keeps every injection full.
110
+ session_start = not _session_has_started(root, hook_input)
111
+ if session_start:
112
+ digest_block = _build_contract_digest(root, host, adapter)
108
113
  else:
109
- header = f"Task: {task_path}\nStatus: {status}\nSource: {source}"
110
- return "\n\n".join(
111
- [
112
- *preamble,
113
- _build_contract_digest(root, host, adapter),
114
- f"<workflow-state>\n{header}\n{body}\n</workflow-state>",
115
- ]
114
+ contracts, _warning = _load_contract_registry(root)
115
+ digest_block = (
116
+ f'<contract-fingerprint value="{contract_fingerprint(root, contracts)}"/>'
117
+ )
118
+ anchor_block = _decision_anchor_block(root, task_path, status)
119
+ blocks = [*preamble, digest_block]
120
+ if anchor_block:
121
+ blocks.append(anchor_block)
122
+ contract_block = _stage_contract_block(root, task_path, status)
123
+ if contract_block:
124
+ blocks.append(contract_block)
125
+ blocks.append(
126
+ f"<workflow-state{_workflow_state_attrs(task_path, status, source)}>"
127
+ f"\n{body}\n</workflow-state>"
116
128
  )
129
+ return "\n\n".join(blocks)
130
+
131
+
132
+ def _session_has_started(root: Path, hook_input: dict[str, Any]) -> bool:
133
+ """True once the hook session holds a task activation state file.
134
+
135
+ Resolving the context key and probing the session directory keeps this
136
+ conservative: any resolution failure falls back to the full digest
137
+ (reported as "not started").
138
+ """
139
+ try:
140
+ from runtime.session_state import resolve_context_key, sessions_dir
141
+ except Exception:
142
+ return False
143
+ try:
144
+ context_key = resolve_context_key(hook_input)
145
+ except Exception:
146
+ return False
147
+ if not context_key:
148
+ return False
149
+ return (sessions_dir(root) / f"{context_key}.json").exists()
150
+
151
+
152
+ def _xml_attr(value: Any) -> str:
153
+ # Delegates to the fact-view implementation: the escaping rules must stay
154
+ # identical across the decision-anchor and stage-contract blocks.
155
+ from services.fact_view import xml_attr
156
+
157
+ return xml_attr(value)
158
+
159
+
160
+ def _workflow_state_attrs(
161
+ task_path: str | None, status: str, source: str
162
+ ) -> str:
163
+ """Structured fact header (context-injection.md, stage 1): the machine
164
+ picks task/status/source off the attributes; humans read the body."""
165
+ attrs = [f'status="{_xml_attr(status)}"', f'source="{_xml_attr(source)}"']
166
+ if task_path:
167
+ attrs.insert(0, f'task="{_xml_attr(task_path)}"')
168
+ return "".join(f" {attr}" for attr in attrs)
169
+
170
+
171
+ DECISION_ANCHOR_STATES = ("planning", "in_progress", "review")
172
+ STAGE_CONTRACT_STATES = ("in_progress", "review")
173
+
174
+
175
+ def _effective_task_status(
176
+ root: Path, task_path: str, status: str
177
+ ) -> str | None:
178
+ """Delegated subtasks read the underlying task's status; every other
179
+ status is already effective."""
180
+ if status != "delegated_subtask":
181
+ return status
182
+ try:
183
+ data = json.loads(
184
+ (root / task_path / "task.json").read_text(encoding="utf-8")
185
+ )
186
+ effective_status = data.get("status")
187
+ except (OSError, json.JSONDecodeError):
188
+ return None
189
+ return effective_status if isinstance(effective_status, str) else None
190
+
191
+
192
+ def _decision_anchor_block(
193
+ root: Path, task_path: str | None, status: str
194
+ ) -> str | None:
195
+ """Compact decision facts (why this task, what done means, what was
196
+ rejected) for states where they steer execution. Delegated subtasks read
197
+ the underlying task's status. Absent anchor file or terminal states
198
+ inject nothing."""
199
+ if not task_path:
200
+ return None
201
+ effective_status = _effective_task_status(root, task_path, status)
202
+ if effective_status not in DECISION_ANCHOR_STATES:
203
+ return None
204
+ try:
205
+ from services.fact_view import parse_decision_anchor
206
+
207
+ text = (root / task_path / "decision-anchor.md").read_text(
208
+ encoding="utf-8"
209
+ )
210
+ parsed = parse_decision_anchor(text)
211
+ except (OSError, UnicodeDecodeError):
212
+ # Absent or undecodable anchors are routine: no block, no noise.
213
+ return None
214
+ except Exception as error:
215
+ # Anything else leaves a trace so silent guard loss stays diagnosable
216
+ # (stdout is the injection channel, stderr is safe).
217
+ sys.stderr.write(f"decision-anchor degraded: {error}\n")
218
+ return None
219
+ if not parsed["goal"] and not parsed["acceptanceCriteria"]:
220
+ return None
221
+ lines = [f'<decision-anchor task="{_xml_attr(task_path)}">']
222
+ if parsed["goal"]:
223
+ lines.append(f"Goal: {parsed['goal'].splitlines()[0][:160]}")
224
+ if parsed["acceptanceCriteria"]:
225
+ items = "; ".join(
226
+ f"{item['id']} {item['text'][:80]}"
227
+ for item in parsed["acceptanceCriteria"][:8]
228
+ )
229
+ lines.append(f"Acceptance: {items}")
230
+ if parsed["rejectedOptions"]:
231
+ lines.append("Rejected: " + "; ".join(parsed["rejectedOptions"][:6]))
232
+ lines.append("</decision-anchor>")
233
+ return "\n".join(lines)
234
+
235
+
236
+ def _stage_contract_block(
237
+ root: Path, task_path: str | None, status: str
238
+ ) -> str | None:
239
+ """Implementation contract (edit scope, specs to read, gates preview,
240
+ declared verification commands) for states where it steers execution.
241
+ Data comes from the frozen task artifacts via services.fact_view — the
242
+ single source shared with the MCP task_scope tool. Delegated subtasks
243
+ render the parent scope as a read-only reference."""
244
+ if not task_path:
245
+ return None
246
+ effective_status = _effective_task_status(root, task_path, status)
247
+ if effective_status not in STAGE_CONTRACT_STATES:
248
+ return None
249
+ try:
250
+ from services.context_paths import load_scope_rules
251
+ from services.fact_view import (
252
+ build_stage_contract,
253
+ file_scope_whitelist,
254
+ parse_decision_anchor,
255
+ spec_pointer_files,
256
+ )
257
+
258
+ task_dir = root / task_path
259
+ whitelist = file_scope_whitelist(root, task_dir)
260
+ spec_files = spec_pointer_files(task_dir)
261
+ rules = load_scope_rules(root)
262
+ anchor_path = task_dir / "decision-anchor.md"
263
+ try:
264
+ parsed = parse_decision_anchor(
265
+ anchor_path.read_text(encoding="utf-8")
266
+ )
267
+ except (OSError, UnicodeDecodeError):
268
+ # Absent or undecodable anchor: scope/gates still render; the
269
+ # verify line is dropped by the empty results.
270
+ parsed = {"validationCommands": []}
271
+ except Exception as error:
272
+ # Never silently kill the guard block: leave a degradation trace
273
+ # on stderr (stdout is the injection channel).
274
+ sys.stderr.write(f"stage-contract degraded: {error}\n")
275
+ return None
276
+ return build_stage_contract(
277
+ task_path,
278
+ whitelist,
279
+ spec_files,
280
+ parsed,
281
+ mutable=status != "delegated_subtask",
282
+ rules=rules,
283
+ )
284
+ except Exception as error:
285
+ sys.stderr.write(f"stage-contract degraded: {error}\n")
286
+ return None
117
287
 
118
288
 
119
289
  def _load_breadcrumbs(root: Path) -> dict[str, str]:
@@ -134,6 +304,29 @@ def _load_breadcrumbs(root: Path) -> dict[str, str]:
134
304
  }
135
305
 
136
306
 
307
+ def contract_fingerprint(root: Path, contracts: list[dict[str, Any]]) -> str:
308
+ digest = hashlib.sha256()
309
+ digest.update(
310
+ json.dumps(
311
+ contracts,
312
+ ensure_ascii=False,
313
+ sort_keys=True,
314
+ # Compact separators keep the bytes identical to the JS
315
+ # stableStringify implementations (see context-injection.md).
316
+ separators=(",", ":"),
317
+ ).encode("utf-8")
318
+ )
319
+ for contract in contracts:
320
+ path = contract.get("path")
321
+ if not isinstance(path, str) or not path.strip():
322
+ continue
323
+ try:
324
+ digest.update((root / path).read_bytes())
325
+ except OSError:
326
+ digest.update(f"missing:{path}".encode("utf-8"))
327
+ return digest.hexdigest()[:16]
328
+
329
+
137
330
  def _load_contract_registry(
138
331
  root: Path,
139
332
  ) -> tuple[list[dict[str, Any]], str | None]:
@@ -172,25 +365,10 @@ def _build_contract_digest(
172
365
  adapter: str,
173
366
  ) -> str:
174
367
  contracts, warning = _load_contract_registry(root)
175
- digest = hashlib.sha256()
176
- digest.update(
177
- json.dumps(
178
- contracts,
179
- ensure_ascii=False,
180
- sort_keys=True,
181
- ).encode("utf-8")
182
- )
183
- for contract in contracts:
184
- path = contract.get("path")
185
- if not isinstance(path, str) or not path.strip():
186
- continue
187
- try:
188
- digest.update((root / path).read_bytes())
189
- except OSError:
190
- digest.update(f"missing:{path}".encode("utf-8"))
368
+ fingerprint = contract_fingerprint(root, contracts)
191
369
  lines = [
192
370
  f'<cowork-runtime host="{host}" adapter="{adapter}">',
193
- f'<contract-digest fingerprint="{digest.hexdigest()[:16]}">',
371
+ f'<contract-digest fingerprint="{fingerprint}">',
194
372
  (
195
373
  "policy: repeat this short digest every hook; "
196
374
  "read full spec files only before listed actions."
@@ -276,10 +454,12 @@ def _subagent_runtime_lines(context: dict[str, Any]) -> list[str]:
276
454
  if isinstance(context.get("assignment"), dict)
277
455
  else {}
278
456
  )
457
+ # No "Scope: subagent" line here: the stage-contract block owns the scope
458
+ # declaration and renders the parent task's scope as a read-only reference
459
+ # for delegated sessions (build_stage_contract(mutable=False)).
279
460
  lines = [
280
461
  f"Runtime context: {context.get('runtime_context_id')}",
281
462
  f"Agent: {context.get('agent_type') or 'unknown'}",
282
- "Scope: subagent",
283
463
  "Do not run standalone lifecycle commands, resume, archive, commit, or spawn.",
284
464
  ]
285
465
  goal = assignment.get("goal")
@@ -0,0 +1,14 @@
1
+ """Allow CLI adapter scripts to import the scripts package when run directly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ SCRIPTS_DIR = Path(__file__).resolve().parents[2]
9
+ if str(SCRIPTS_DIR) not in sys.path:
10
+ sys.path.insert(0, str(SCRIPTS_DIR))
11
+
12
+ from adapters.cli.encoding import configure_cli_encoding
13
+
14
+ configure_cli_encoding()
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Read-only MCP stdio server exposing the cowork-flow fact layer.
4
+
5
+ Stage 3 of docs/direction.md: any MCP client can query task facts without
6
+ pulling the workflow into a prompt. Deliberately dependency-free — the MCP
7
+ stdio transport is newline-delimited JSON-RPC 2.0, small enough to serve on
8
+ the standard library while the ecosystem settles (see
9
+ spec/contracts/fact-layer-access.md).
10
+
11
+ Read-only guarantee: both tools only read repository state (fact view and
12
+ the active task tree); there is no write path here. Writes keep flowing
13
+ through the CLI lifecycle gates.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ if __package__:
23
+ from . import _bootstrap as _bootstrap # noqa: F401
24
+ else:
25
+ import _bootstrap # noqa: F401
26
+
27
+ from infra.paths import get_repo_root
28
+
29
+ PROTOCOL_VERSION = "2025-06-18"
30
+ SERVER_INFO = {"name": "cowork-flow-facts", "version": "1.0.0"}
31
+
32
+ TOOLS = [
33
+ {
34
+ "name": "task_state",
35
+ "description": (
36
+ "Fact view for one task: task.json (with revision), decision-anchor "
37
+ "essentials (goal, acceptance criteria, rejected options), plan "
38
+ "binding, bound sessions, and the trusted state snapshot. Omit "
39
+ "`task` to use the caller's session-bound active task."
40
+ ),
41
+ "inputSchema": {
42
+ "type": "object",
43
+ "properties": {
44
+ "task": {
45
+ "type": "string",
46
+ "description": "Task directory or name.",
47
+ },
48
+ },
49
+ },
50
+ },
51
+ {
52
+ "name": "task_list",
53
+ "description": (
54
+ "Active tasks overview: name, path, status, assignee, parent/child "
55
+ "links, and which one the current session has bound."
56
+ ),
57
+ "inputSchema": {"type": "object", "properties": {}},
58
+ },
59
+ {
60
+ "name": "task_specs",
61
+ "description": (
62
+ "Spec files the implementing agent should read for a task, "
63
+ "dispatched by its dev_type (base guides + domain skills + "
64
+ "backend/frontend/spec pointers). Read them before coding."
65
+ ),
66
+ "inputSchema": {
67
+ "type": "object",
68
+ "properties": {
69
+ "task": {
70
+ "type": "string",
71
+ "description": "Task directory or name.",
72
+ },
73
+ },
74
+ },
75
+ },
76
+ {
77
+ "name": "task_scope",
78
+ "description": (
79
+ "Edit-scope verdict for a task. Without `path`: the file-scope "
80
+ "whitelist summary (only file/planned-file/deleted-file entries "
81
+ "authorize edits; directories authorize nothing). With `path`: "
82
+ "an inScope verdict for that one file. Edits outside the scope "
83
+ "are review blockers."
84
+ ),
85
+ "inputSchema": {
86
+ "type": "object",
87
+ "properties": {
88
+ "task": {
89
+ "type": "string",
90
+ "description": "Task directory or name.",
91
+ },
92
+ "path": {
93
+ "type": "string",
94
+ "description": "Repo-relative file path to check.",
95
+ },
96
+ },
97
+ },
98
+ },
99
+ ]
100
+
101
+
102
+ def _tool_task_state(root: Path, arguments: dict) -> dict:
103
+ from runtime.session_state import get_active_task
104
+ from services.fact_view import build_fact_view
105
+ from services.task_repository import TaskRepository, TaskRepositoryError
106
+
107
+ target = arguments.get("task")
108
+ if not target:
109
+ target = get_active_task(root).task_path
110
+ if not target:
111
+ return {"schemaVersion": 1, "task": None, "reason": "no-active-task"}
112
+ try:
113
+ task_dir = TaskRepository(root).resolve(target)
114
+ except TaskRepositoryError as error:
115
+ raise ValueError(str(error)) from error
116
+ if not (Path(task_dir) / "task.json").is_file():
117
+ raise ValueError(f"task.json not found under {task_dir}")
118
+ return build_fact_view(root, Path(task_dir))
119
+
120
+
121
+ def _tool_task_list(root: Path, _arguments: dict) -> dict:
122
+ from adapters.cli.task_tree_commands import _list_task_records
123
+
124
+ records, error = _list_task_records(root, mine=False, status=None)
125
+ if error:
126
+ return {"tasks": [], "count": 0, "error": error}
127
+ return {"tasks": records, "count": len(records)}
128
+
129
+
130
+ def _resolve_task_dir(root: Path, target: str | None) -> Path:
131
+ from runtime.session_state import get_active_task
132
+ from services.task_repository import TaskRepository, TaskRepositoryError
133
+
134
+ if not target:
135
+ target = get_active_task(root).task_path
136
+ if not target:
137
+ raise ValueError("no-active-task")
138
+ try:
139
+ task_dir = TaskRepository(root).resolve(target)
140
+ except TaskRepositoryError as error:
141
+ raise ValueError(str(error)) from error
142
+ try:
143
+ resolved = Path(task_dir).resolve()
144
+ resolved.relative_to(Path(root).resolve())
145
+ except ValueError as error:
146
+ raise ValueError("task-outside-repo") from error
147
+ if not (Path(task_dir) / "task.json").is_file():
148
+ raise ValueError(f"task.json not found under {task_dir}")
149
+ return Path(task_dir)
150
+
151
+
152
+ def _tool_task_scope(root: Path, arguments: dict) -> dict:
153
+ from services.fact_view import file_scope_whitelist, path_in_scope
154
+
155
+ task_dir = _resolve_task_dir(root, arguments.get("task"))
156
+ whitelist = file_scope_whitelist(root, task_dir)
157
+ candidate = arguments.get("path")
158
+ if candidate:
159
+ verdict = path_in_scope(whitelist, str(candidate))
160
+ return {"taskDir": task_dir.name, **verdict}
161
+ return {
162
+ "taskDir": task_dir.name,
163
+ "whitelist": whitelist,
164
+ "count": len(whitelist),
165
+ }
166
+
167
+
168
+ def _tool_task_specs(root: Path, arguments: dict) -> dict:
169
+ from services.context_discovery import implement_spec_entries
170
+ from services.fact_view import _read_json
171
+
172
+ task_dir = _resolve_task_dir(root, arguments.get("task"))
173
+ task = _read_json(task_dir / "task.json")
174
+ dev_type = task.get("dev_type") if isinstance(task, dict) else None
175
+ specs = implement_spec_entries(root, dev_type)
176
+ return {
177
+ "taskDir": task_dir.name,
178
+ "devType": dev_type,
179
+ "specs": specs,
180
+ "count": len(specs),
181
+ }
182
+
183
+
184
+ TOOL_HANDLERS = {
185
+ "task_state": _tool_task_state,
186
+ "task_list": _tool_task_list,
187
+ "task_scope": _tool_task_scope,
188
+ "task_specs": _tool_task_specs,
189
+ }
190
+
191
+
192
+ def handle_request(root: Path, request: dict) -> dict | None:
193
+ """Dispatch one decoded JSON-RPC message. Notifications (no id member)
194
+ yield None and are never answered — including initialize/ping/tools/list,
195
+ which are requests only when an id is present."""
196
+ method = request.get("method")
197
+ request_id = request.get("id")
198
+ if "id" not in request:
199
+ return None
200
+ if method == "initialize":
201
+ requested = (request.get("params") or {}).get("protocolVersion")
202
+ return {
203
+ "jsonrpc": "2.0",
204
+ "id": request_id,
205
+ "result": {
206
+ "protocolVersion": requested or PROTOCOL_VERSION,
207
+ "capabilities": {"tools": {"listChanged": False}},
208
+ "serverInfo": SERVER_INFO,
209
+ },
210
+ }
211
+ if method == "ping":
212
+ return {"jsonrpc": "2.0", "id": request_id, "result": {}}
213
+ if method == "tools/list":
214
+ return {
215
+ "jsonrpc": "2.0",
216
+ "id": request_id,
217
+ "result": {"tools": TOOLS},
218
+ }
219
+ if method == "tools/call":
220
+ params = request.get("params") or {}
221
+ name = params.get("name")
222
+ handler = TOOL_HANDLERS.get(name)
223
+ if handler is None:
224
+ return {
225
+ "jsonrpc": "2.0",
226
+ "id": request_id,
227
+ "result": {
228
+ "content": [
229
+ {"type": "text", "text": f"unknown tool: {name}"}
230
+ ],
231
+ "isError": True,
232
+ },
233
+ }
234
+ try:
235
+ payload = handler(root, params.get("arguments") or {})
236
+ except Exception as error: # tool errors ride the result, not the frame
237
+ return {
238
+ "jsonrpc": "2.0",
239
+ "id": request_id,
240
+ "result": {
241
+ "content": [
242
+ {
243
+ "type": "text",
244
+ "text": f"{type(error).__name__}: {error}",
245
+ }
246
+ ],
247
+ "isError": True,
248
+ },
249
+ }
250
+ return {
251
+ "jsonrpc": "2.0",
252
+ "id": request_id,
253
+ "result": {
254
+ "content": [
255
+ {
256
+ "type": "text",
257
+ "text": json.dumps(
258
+ payload, ensure_ascii=False, indent=2
259
+ ),
260
+ }
261
+ ]
262
+ },
263
+ }
264
+ if request_id is None:
265
+ return None
266
+ return {
267
+ "jsonrpc": "2.0",
268
+ "id": request_id,
269
+ "error": {"code": -32601, "message": f"method not found: {method}"},
270
+ }
271
+
272
+
273
+ def main() -> int:
274
+ root = get_repo_root()
275
+ for line in sys.stdin:
276
+ line = line.strip()
277
+ if not line:
278
+ continue
279
+ try:
280
+ request = json.loads(line)
281
+ except json.JSONDecodeError as error:
282
+ sys.stdout.write(
283
+ json.dumps(
284
+ {
285
+ "jsonrpc": "2.0",
286
+ "id": None,
287
+ "error": {"code": -32700, "message": str(error)},
288
+ }
289
+ )
290
+ + "\n"
291
+ )
292
+ sys.stdout.flush()
293
+ continue
294
+ if not isinstance(request, dict):
295
+ continue
296
+ response = handle_request(root, request)
297
+ if response is None:
298
+ continue
299
+ sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
300
+ sys.stdout.flush()
301
+ return 0
302
+
303
+
304
+ if __name__ == "__main__":
305
+ sys.exit(main())
@@ -58,17 +58,56 @@ def collect_changed_paths(repo_root: Path) -> list[str]:
58
58
  return [changed.path for changed in collect_changed_files(repo_root)]
59
59
 
60
60
 
61
- def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
62
- completed = subprocess.run(
63
- ["git", *args],
64
- cwd=cwd,
65
- text=True,
66
- encoding="utf-8",
67
- errors="replace",
68
- stdout=subprocess.PIPE,
69
- stderr=subprocess.PIPE,
70
- check=False,
61
+ def collect_changed_paths_since(
62
+ repo_root: Path, base_commit: str | None
63
+ ) -> tuple[list[str], bool]:
64
+ """Merge the baseline..HEAD diff with the working-tree status:
65
+ (paths, degraded). Committing during a task no longer hides changed
66
+ files from review.
67
+
68
+ degraded=True when there is no baseline, HEAD does not exist, or the
69
+ diff fails (e.g. a rebase orphaned the baseline) — callers fall back to
70
+ status-only, which is exactly the pre-baseline behavior."""
71
+ status_paths = collect_changed_paths(repo_root)
72
+ if not base_commit:
73
+ return status_paths, True
74
+ rc, stdout, _ = _run_git_command(
75
+ ["diff", "--name-only", f"{base_commit}..HEAD"],
76
+ cwd=repo_root,
71
77
  )
78
+ if rc != 0:
79
+ return status_paths, True
80
+ diff_paths = [line.strip() for line in stdout.splitlines() if line.strip()]
81
+ merged = sorted(set(status_paths) | set(diff_paths))
82
+ return merged, False
83
+
84
+
85
+ def current_head(repo_root: Path) -> str | None:
86
+ """Full HEAD sha of the repo, or None when no commits exist yet."""
87
+ rc, stdout, _ = _run_git_command(["rev-parse", "HEAD"], cwd=repo_root)
88
+ if rc != 0:
89
+ return None
90
+ value = stdout.strip()
91
+ return value or None
92
+
93
+
94
+ def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
95
+ try:
96
+ completed = subprocess.run(
97
+ ["git", *args],
98
+ cwd=cwd,
99
+ text=True,
100
+ encoding="utf-8",
101
+ errors="replace",
102
+ stdout=subprocess.PIPE,
103
+ stderr=subprocess.PIPE,
104
+ check=False,
105
+ )
106
+ except OSError:
107
+ # git unavailable (not installed, or PATH stripped): callers degrade
108
+ # through the existing rc!=0 paths — no HEAD means no baseline, no
109
+ # changes means status-only review.
110
+ return 127, "", "git unavailable"
72
111
  return completed.returncode, completed.stdout, completed.stderr
73
112
 
74
113