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.
- package/CHANGELOG.md +56 -0
- package/README.md +11 -2
- package/package.json +1 -1
- package/presets/dsh/plugins/workflow-state.js +25 -15
- package/src/cli.js +6 -0
- package/src/commands/mcp-state.js +66 -0
- package/template/.claude/hooks/inject-workflow-state.py +6 -5
- package/template/.codex/hooks/inject-workflow-state.py +9 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/adapters/claude-code/adapter.yaml +1 -0
- package/template/.cowork-flow/adapters/codex/adapter.yaml +1 -0
- package/template/.cowork-flow/adapters/dsh/adapter.yaml +1 -0
- package/template/.cowork-flow/adapters/opencode/adapter.yaml +1 -0
- package/template/.cowork-flow/adapters/zcode/adapter.yaml +1 -0
- package/template/.cowork-flow/scripts/adapters/cli/state.py +121 -0
- package/template/.cowork-flow/scripts/adapters/cli/subagent.py +28 -0
- package/template/.cowork-flow/scripts/adapters/cli/task.py +6 -4
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +5 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +9 -0
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +208 -28
- package/template/.cowork-flow/scripts/adapters/mcp/_bootstrap.py +14 -0
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +305 -0
- package/template/.cowork-flow/scripts/infra/git_snapshot.py +49 -10
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +3 -1
- package/template/.cowork-flow/scripts/run.py +3 -0
- package/template/.cowork-flow/scripts/services/context_discovery.py +26 -0
- package/template/.cowork-flow/scripts/services/context_paths.py +79 -9
- package/template/.cowork-flow/scripts/services/fact_view.py +439 -0
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +34 -3
- package/template/.cowork-flow/scripts/services/task_context.py +2 -21
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +202 -3
- package/template/.cowork-flow/scripts/services/workflow_runtime.py +45 -0
- package/template/.cowork-flow/spec/contracts/context-injection.md +208 -0
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +7 -0
- package/template/.cowork-flow/spec/contracts/fact-layer-access.md +50 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +10 -5
- package/template/.cowork-flow/spec/runtime/scope-rules.json +16 -0
- package/template/.opencode/plugins/cowork-flow.js +338 -11
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/hooks.json +1 -1
- package/template/.zcode/hooks/inject-context.js +418 -22
- 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
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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="{
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|