@guanlin4924/devflow-pi 0.1.95 → 0.1.96

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
@@ -11,7 +11,7 @@ Devflow Pi 是一个面向项目研发交付的独立 OMP 产品入口,内置
11
11
  安装与启动:
12
12
 
13
13
  ```bash
14
- npm install -g @guanlin4924/devflow-pi@0.1.95
14
+ npm install -g @guanlin4924/devflow-pi@0.1.96
15
15
  cd /path/to/your-project
16
16
  devflow-pi init
17
17
  devflow-pi doctor
@@ -115,7 +115,7 @@ Devflow 会在输入框上方显示“当前 Feature 交付链路”:它基于
115
115
  - 验收失败不会创建另一套人工台账,而是进入 `acceptance-remediation`:诊断 → 修复 → code-index 刷新 → 原冻结 `TC-*` 重跑。
116
116
  - CP-4 通过后只进入一个 `workflow-release-closeout` 步骤,统一写入 `.devflow/planning/milestones/current-CLOSEOUT.md` 并更新项目状态、导航和版本记录。
117
117
 
118
- 正常使用不需要记忆底层命令:在 Devflow 工作流中按步骤继续即可。需要排查时,可用 `devflow_acceptance_tests` 查看冻结套件,或由工作流调用 `devflow_tdd_evidence` 与 `devflow_run_acceptance_tests` 生成 Core 指定写入的证据;不要手写“已测试”来替代结果文件。本地 hook 拒绝 Agent 文件工具与 shell 访问 `control.db` 及 Devflow 数据目录 `projects/`、拒绝写入 canonical 验收结果文件(Read 类读取工具仍可读取结果文件;shell 命令仅在指向真实控制库路径时才被拒,单纯出现 `control.db` 文本不受影响)。它是误操作防护,不构成对同系统用户恶意进程的安全隔离。
118
+ 正常使用不需要记忆底层命令:在 Devflow 工作流中按步骤继续即可。需要排查时,可用 `devflow_acceptance_tests` 查看冻结套件,或由工作流调用 `devflow_tdd_evidence` 与 `devflow_run_acceptance_tests` 生成 Core 指定写入的证据;不要手写“已测试”来替代结果文件。本地 hook 拒绝 Agent 文件工具与 shell 访问 `control.db` 及 Devflow 数据目录 `projects/`、拒绝写入 canonical 验收结果文件,并默认拒绝访问 `.devflow/runs/` 运行工件,仅白名单放行 `result.json`(Read 类读取工具仍可读取 `acceptance-results.json`;trace、截图、网络工件和 `runtime.log` 由用户本地查看)。它是防误用,不构成对同系统用户恶意进程的安全隔离。
119
119
 
120
120
  ### Core 软门禁模式
121
121
 
@@ -151,3 +151,33 @@ npm install -g @guanlin4924/devflow-pi@latest
151
151
  - 索引是加速器,不是真相源;过期时回退到 LSP、搜索、源码和测试。
152
152
  - 人工 checkpoint 不得在 headless 模式静默通过。
153
153
  - semantic correctness 不能靠 prompt 保证,必须由契约、确定性校验、独立审查和可复现测试共同约束。
154
+ ## Devflow MCP(阶段 B)
155
+
156
+ `devflow-pi mcp --client <name>` 通过 stdio 提供四个 feature 级工具:`validate_feature`、`run_feature`、`get_feature_report` 和 `dsl_draft_validate`。Server 只做输入校验、授权、审计和调用包内 Core;不读取或导出 trace、截图等运行工件,也不能确认批准。缺批准的用例仍需在 `/devflow` 面板完成批准。
157
+
158
+ 授权文件位于 `$DEVFLOW_HOME/mcp.json`(默认 `~/.devflow-pi/mcp.json`),例如:
159
+
160
+ ```json
161
+ {
162
+ "schema_version": 1,
163
+ "clients": { "claude-code": { "projects": {
164
+ "/abs/path/to/project": {
165
+ "features": ["order"],
166
+ "permissions": ["validate", "run", "report", "dsl_draft"],
167
+ "environment_profiles": ["local"]
168
+ }
169
+ } } }
170
+ }
171
+ ```
172
+
173
+ 这里的 `--client` 是声明式身份,用于匹配授权和写入审计,不是密码学认证;stdio 调用方与 Server 是同一台机器上的同一用户。Claude Code 注册:`claude mcp add devflow-acceptance -- devflow-pi mcp --client claude-code`。Codex 在 `config.toml` 中注册:
174
+
175
+ ```toml
176
+ [mcp_servers.devflow-acceptance]
177
+ command = "devflow-pi"
178
+ args = ["mcp", "--client", "codex"]
179
+ ```
180
+
181
+ `run_feature` 是同步调用,默认最多运行 2 小时,可用 `--run-timeout-seconds` 下调;客户端也应把 MCP 请求超时设得足够长,并启用 progress。审计写入 `$DEVFLOW_HOME/mcp-audit/`。批准仍只能在 `/devflow` 面板完成。
182
+
183
+ Core 在 MCP Server 的进程环境中执行,因此注册 Server 时要把被测环境需要的变量一并传入:HTTP adapter 的基础地址变量(如 `DEVFLOW_TEST_BASE_URL`)和环境 profile 中 `secret_env` 声明的密钥变量;缺失时对应用例会是 failed 或 blocked。`mcp.json` 的 `environment_profiles` 只约束 `devflow_playwright` 绑定引用的 profile;HTTP adapter 的目标地址来自这些环境变量,不受 `mcp.json` 授权约束,应由启动 Server 的人控制。`run_feature` 在有用例未通过时仍返回报告,是否通过以其中的 `gate` 为准。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.95",
3
+ "version": "0.1.96",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -23,6 +23,7 @@
23
23
  "node": ">=20"
24
24
  },
25
25
  "dependencies": {
26
+ "@modelcontextprotocol/sdk": "1.32.1",
26
27
  "@oh-my-pi/pi-coding-agent": "17.2.7",
27
28
  "@playwright/test": "1.63.0",
28
29
  "@plurnk/plurnk-mimetypes-grammar-cpp": "1.16.1",
@@ -3781,6 +3781,7 @@ export default function devflowCore(pi: ExtensionAPI) {
3781
3781
  description: [
3782
3782
  "Validate or execute the adapters bound to the frozen TC acceptance suite: command, portable HTTP, and devflow_playwright (the Core-owned Playwright Runtime driven by a pinned DSL).",
3783
3783
  "Actions: validate (bindings and suite), validate_human (manual TCs), run (executes every automated TC and writes the canonical acceptance-results.json; Core is the only writer), validate_results (re-checks recorded results against current approvals and hashes), gc_runs (prunes .devflow/runs/<feature> execution directories per retention rules; dry-run by default, pass write=true to delete; optional retentionDays / unreferencedTtlDays).",
3784
+ "Failure analysis reads only the Core-produced result.json summary; trace files and screenshots are not readable by Agent tools and are for the user to inspect locally with Playwright in a terminal.",
3784
3785
  "devflow_playwright bindings require CURRENT human approvals created with devflow_approval and confirmed by the user in /devflow control: an execution_plan approval per TC (pins the DSL, TC and suite) and an environment_profile approval for the target profile; manual or downgraded TCs need a human_result / mode_override approval. You cannot confirm approvals yourself.",
3785
3786
  "Hard gate: a missing, superseded or expired approval, or a DSL/profile/suite/evidence hash that changed after the approval or the run, makes run and validate_results return ok=false and blocks run-acceptance-tests from completing; there is no soft override and results must never be written by hand.",
3786
3787
  ].join(" "),
@@ -711,6 +711,251 @@ def tool_touches_control_store(event: dict[str, Any], root: Path) -> tuple[bool,
711
711
  return False, []
712
712
 
713
713
 
714
+ # MCP authorization and audit data are user-local security state. This guard
715
+ # is deliberately narrower than DEVFLOW_HOME: only mcp.json and mcp-audit/
716
+ # are protected, and the decision is independent of soft-gate policy.
717
+ def path_is_mcp_protected(raw: str, cwd: Path) -> bool:
718
+ candidate = Path(_expand_shell_path(str(raw)))
719
+ if not candidate.is_absolute():
720
+ candidate = cwd / candidate
721
+ lexical = Path(os.path.normpath(str(candidate)))
722
+ resolved = Path(os.path.realpath(str(candidate)))
723
+ home = devflow_home()
724
+ config = home / "mcp.json"
725
+ audit = home / "mcp-audit"
726
+ for item in (lexical, resolved):
727
+ if item == config or _under(item, audit):
728
+ return True
729
+ return False
730
+
731
+
732
+ def _analyze_mcp_command(command: str, cwd: Path, depth: int = 0) -> bool:
733
+ tokens = _shell_tokens(command)
734
+ for segment in _segments(tokens):
735
+ index = 0
736
+ while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
737
+ index += 1
738
+ if index >= len(segment):
739
+ continue
740
+ program = os.path.basename(segment[index]).lower()
741
+ operands = segment[index + 1:]
742
+ if program in SHELL_PROGRAMS and depth < 3:
743
+ for pos, operand in enumerate(operands):
744
+ if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
745
+ if _analyze_mcp_command(operands[pos + 1], cwd, depth + 1):
746
+ return True
747
+ ignored: set[int] = set()
748
+ if program in {"echo", "printf"}:
749
+ ignored.update(range(len(operands)))
750
+ elif program in {"rg", "grep", "ag", "ack"}:
751
+ seen_pattern = False
752
+ pos = 0
753
+ while pos < len(operands):
754
+ operand = operands[pos]
755
+ if operand in {"-e", "--regexp"} and pos + 1 < len(operands):
756
+ ignored.add(pos + 1)
757
+ pos += 2
758
+ continue
759
+ if operand.startswith("--regexp=") or (not operand.startswith("-") and not seen_pattern):
760
+ ignored.add(pos)
761
+ seen_pattern = True
762
+ pos += 1
763
+ elif program == "git":
764
+ for pos, operand in enumerate(operands):
765
+ if operand in {"-m", "--message"} and pos + 1 < len(operands):
766
+ ignored.add(pos + 1)
767
+ previous = ""
768
+ for pos, operand in enumerate(operands):
769
+ if previous in REDIRECT_OPERATORS and path_is_mcp_protected(operand, cwd):
770
+ return True
771
+ if pos in ignored:
772
+ previous = operand
773
+ continue
774
+ if operand in REDIRECT_OPERATORS or operand.startswith("-"):
775
+ previous = operand
776
+ continue
777
+ if path_is_mcp_protected(operand, cwd):
778
+ return True
779
+ previous = operand
780
+ return False
781
+
782
+
783
+ def tool_touches_mcp_config(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
784
+ cwd = Path(str(event.get("cwd") or root)).expanduser()
785
+ blocked = [raw for raw in _raw_tool_paths(event) if path_is_mcp_protected(raw, cwd)]
786
+ if blocked:
787
+ return True, sorted(dict.fromkeys(blocked))
788
+ command = command_text(event)
789
+ if command and _analyze_mcp_command(command, cwd):
790
+ return True, ["<shell command>"]
791
+ return False, []
792
+
793
+
794
+ # Run artifacts are user-local evidence, not agent-readable source. Keep this
795
+ # guard separate from the control-store guard: it is always on and its only
796
+ # read exception is the Core-produced result.json summary.
797
+ def _run_root(cwd: Path) -> Path:
798
+ # macOS commonly exposes /var as a symlink to /private/var; use the
799
+ # canonical cwd so lexical and realpath checks compare in one namespace.
800
+ return Path(os.path.realpath(str(cwd))) / ".devflow" / "runs"
801
+
802
+
803
+ def _run_relative(raw: str, cwd: Path, anchor: Path | None = None) -> tuple[Path, Path, Path]:
804
+ # The protected root is anchored at the project root, never at the current
805
+ # shell cwd, so `cd .devflow && cat runs/...` still resolves into it.
806
+ expanded = _expand_shell_path(raw)
807
+ cwd_text = str(cwd)
808
+ expanded = re.sub(r"\$\{PWD\}|\$PWD\b", cwd_text, expanded)
809
+ candidate = Path(expanded.replace("\\", "/"))
810
+ if not candidate.is_absolute():
811
+ candidate = cwd / candidate
812
+ lexical = Path(os.path.normpath(str(candidate)))
813
+ resolved = Path(os.path.realpath(str(candidate)))
814
+ return lexical, resolved, _run_root(anchor or cwd)
815
+
816
+
817
+ def _under_run(path: Path, run_root: Path) -> bool:
818
+ return _under(path, run_root)
819
+
820
+
821
+ def path_is_run_artifact(raw: str, cwd: Path, tool: dict[str, Any] | str) -> bool:
822
+ """Return whether a tool path enters .devflow/runs.
823
+
824
+ ``result.json`` is readable only by pure-read tools. Glob may list the
825
+ runs directory itself; every other path, including result.json writes and
826
+ symlink aliases, remains protected.
827
+ """
828
+ lexical, resolved, run_root = _run_relative(raw, cwd)
829
+ paths = (lexical, resolved)
830
+ tool_event = tool if isinstance(tool, dict) else {"tool_name": tool}
831
+ pure_read = is_pure_read_tool(tool_event)
832
+ name = tool_name(tool_event)
833
+ for item in paths:
834
+ if not _under_run(item, run_root):
835
+ continue
836
+ relative = item.relative_to(run_root).parts
837
+ if name == "glob" and len(relative) == 0:
838
+ continue
839
+ if pure_read and len(relative) == 3 and relative[-1] == "result.json":
840
+ continue
841
+ return True
842
+ return False
843
+
844
+
845
+ RUN_FILE_PROGRAMS = {
846
+ "cat", "cp", "mv", "rm", "tee", "touch", "truncate", "install", "rsync", "scp",
847
+ "tar", "unzip", "zip", "base64", "xxd", "strings", "od", "hexdump", "file", "stat",
848
+ "head", "tail", "less", "more", "sed", "awk", "nl", "bat", "rg", "grep", "find", "fd",
849
+ "ls", "tree", "du", "open", "sha256sum", "shasum", "md5sum", "md5", "cksum", "chmod",
850
+ "chown", "unlink", "shred", "dd", "gzip", "gunzip", "bzip2", "xz", "python", "python3",
851
+ "node", "bun", "deno", "ruby", "perl", "php", "lua",
852
+ }
853
+
854
+
855
+ def _token_is_run_target(token: str, cwd: Path, anchor: Path | None = None) -> bool:
856
+ if not token or token in REDIRECT_OPERATORS or token.startswith("-"):
857
+ return False
858
+ lexical, resolved, run_root = _run_relative(token, cwd, anchor)
859
+ return _under_run(lexical, run_root) or _under_run(resolved, run_root)
860
+
861
+
862
+ def _analyze_run_command(command: str, cwd: Path, depth: int = 0, anchor: Path | None = None) -> bool:
863
+ anchor = anchor or cwd
864
+ tokens = _shell_tokens(command)
865
+ current_cwd = cwd
866
+ for segment in _segments(tokens):
867
+ index = 0
868
+ while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
869
+ index += 1
870
+ if index >= len(segment):
871
+ continue
872
+ program = os.path.basename(segment[index]).lower()
873
+ operands = segment[index + 1:]
874
+ if program in SHELL_PROGRAMS and depth < 3:
875
+ for pos, operand in enumerate(operands):
876
+ if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
877
+ if _analyze_run_command(operands[pos + 1], current_cwd, depth + 1, anchor):
878
+ return True
879
+ if program == "cd" and operands:
880
+ if _token_is_run_target(operands[-1], current_cwd, anchor):
881
+ return True
882
+ candidate = Path(_expand_shell_path(operands[-1]))
883
+ current_cwd = candidate if candidate.is_absolute() else current_cwd / candidate
884
+ current_cwd = Path(os.path.normpath(str(current_cwd)))
885
+ continue
886
+ # Any shell redirection is a real filesystem access, even when the
887
+ # command producing the bytes is otherwise harmless (e.g. echo).
888
+ previous = ""
889
+ for operand in operands:
890
+ if previous in REDIRECT_OPERATORS and _token_is_run_target(operand, current_cwd, anchor):
891
+ return True
892
+ previous = operand
893
+ # Inline interpreters can open arbitrary files. Reject the source
894
+ # text conservatively whenever it names the protected tree.
895
+ if program in {"python", "python3", "node", "bun", "deno", "ruby", "perl", "php"}:
896
+ for pos, operand in enumerate(operands):
897
+ if operand in {"-c", "-e", "--eval"} and pos + 1 < len(operands):
898
+ if ".devflow/runs" in operands[pos + 1]:
899
+ return True
900
+ if any(operand.startswith(prefix) for prefix in ("--eval=", "-e=")) and ".devflow/runs" in operand.split("=", 1)[1]:
901
+ return True
902
+
903
+ # Every non-option operand is a potential filesystem target, regardless
904
+ # of the executable. Only documented text positions are exempt.
905
+ ignored: set[int] = set()
906
+ if program in {"echo", "printf"}:
907
+ ignored.update(range(len(operands)))
908
+ elif program in {"rg", "grep", "ag", "ack"}:
909
+ seen_pattern = False
910
+ pos = 0
911
+ while pos < len(operands):
912
+ operand = operands[pos]
913
+ if operand in {"-e", "--regexp"} and pos + 1 < len(operands):
914
+ ignored.add(pos + 1)
915
+ pos += 2
916
+ continue
917
+ if operand.startswith("--regexp="):
918
+ ignored.add(pos)
919
+ elif not operand.startswith("-") and not seen_pattern:
920
+ ignored.add(pos)
921
+ seen_pattern = True
922
+ pos += 1
923
+ elif program == "git":
924
+ for pos, operand in enumerate(operands):
925
+ if operand in {"-m", "--message"} and pos + 1 < len(operands):
926
+ ignored.add(pos + 1)
927
+ elif operand.startswith("--message=") or operand.startswith("-m="):
928
+ ignored.add(pos)
929
+
930
+ for pos, operand in enumerate(operands):
931
+ if pos in ignored or operand in REDIRECT_OPERATORS:
932
+ continue
933
+ target = operand
934
+ if operand.startswith("--") and "=" in operand:
935
+ target = operand.split("=", 1)[1]
936
+ if not target or target.startswith("-"):
937
+ continue
938
+ if _token_is_run_target(target, current_cwd, anchor):
939
+ return True
940
+ return False
941
+
942
+
943
+ def command_touches_run_artifacts(command: str, cwd: Path | None = None) -> bool:
944
+ """Deny shell access to real run-artifact paths, not textual mentions."""
945
+ return _analyze_run_command(command, cwd or Path.cwd())
946
+
947
+
948
+ def tool_touches_run_artifacts(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
949
+ cwd = Path(str(event.get("cwd") or root)).expanduser()
950
+ blocked = [raw for raw in _raw_tool_paths(event) if path_is_run_artifact(raw, cwd, event)]
951
+ if blocked:
952
+ return True, sorted(dict.fromkeys(blocked))
953
+ command = command_text(event)
954
+ if command and command_touches_run_artifacts(command, cwd):
955
+ return True, ["<shell command>"]
956
+ return False, []
957
+
958
+
714
959
  def evidence_observed(event: dict[str, Any], root: Path, artifacts: list[str]) -> list[str]:
715
960
  name = tool_name(event)
716
961
  if not (any(marker in name for marker in READ_TOOL_MARKERS) or is_command_tool(event)):
@@ -15,8 +15,10 @@ from hook_common import (
15
15
  read_event,
16
16
  record_prechange_fingerprints,
17
17
  required_evidence,
18
+ tool_touches_mcp_config,
18
19
  tool_requests_protected_source,
19
20
  tool_touches_control_store,
21
+ tool_touches_run_artifacts,
20
22
  )
21
23
 
22
24
 
@@ -40,6 +42,13 @@ def main() -> int:
40
42
  event = read_event()
41
43
  root = project_root(event)
42
44
  # Hard protection, evaluated before any soft/strict policy handling.
45
+ mcp_blocked, mcp_paths = tool_touches_mcp_config(event, root)
46
+ if mcp_blocked:
47
+ return deny(
48
+ "MCP_CONFIG_PROTECTED: $DEVFLOW_HOME/mcp.json and $DEVFLOW_HOME/mcp-audit/ are reserved for "
49
+ "Devflow MCP authorization and audit records; ordinary agent file and shell access is denied "
50
+ "regardless of DEVFLOW_SOFT_GATES. Blocked target: " + ", ".join(mcp_paths) + "."
51
+ )
43
52
  control_blocked, control_paths = tool_touches_control_store(event, root)
44
53
  if control_blocked:
45
54
  return deny(
@@ -49,6 +58,15 @@ def main() -> int:
49
58
  "Canonical acceptance-results*.json may be read with the Read/Grep file tools. "
50
59
  f"Use devflow_approval(action=\"propose\"|\"list\") for approvals. Blocked target: {', '.join(control_paths)}."
51
60
  )
61
+ run_blocked, run_paths = tool_touches_run_artifacts(event, root)
62
+ if run_blocked:
63
+ return deny(
64
+ "RUN_ARTIFACTS_PROTECTED: .devflow/runs/ contains trace, screenshots, runtime logs and other "
65
+ "user-local evidence that Agent tools may not read, write or access through bash. Trace and screenshots "
66
+ "are for the user to inspect locally with Playwright in a terminal; Agent failure analysis must read "
67
+ "the Core-produced result.json summary instead. Only pure-read access to a feature execution's "
68
+ "result.json is allowed. Blocked target: " + ", ".join(run_paths) + "."
69
+ )
52
70
  policy = load_policy(root)
53
71
  session_id = ensure_session(root, event)
54
72
  required = required_evidence(root, policy)
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import contextlib
7
+ import fcntl
6
8
  import hashlib
7
9
  import json
8
10
  import os
@@ -35,6 +37,8 @@ ALLOWED_BINDING_MODES = {"automated": ("automated", "manual", "blocked"), "manua
35
37
  RESULT_ORIGINS = {"command": "project_test_command", "http": "core_http_adapter", playwright_adapter.ADAPTER_KIND: playwright_adapter.RESULT_ORIGIN}
36
38
  DEFAULT_RUN_RETENTION_DAYS = 30
37
39
  DEFAULT_UNREFERENCED_RUN_TTL_DAYS = 7
40
+ MAX_REPORT_ERRORS = 50
41
+ MAX_REPORT_ERROR_CHARS = 500
38
42
 
39
43
 
40
44
  def utc_now() -> str:
@@ -427,7 +431,36 @@ def run_command_case(root: Path, argv: list[str]) -> tuple[int, str]:
427
431
  return 127, f"command could not start: {error}"
428
432
 
429
433
 
434
+ @contextlib.contextmanager
435
+ def acceptance_run_lock(root: Path, feature: str):
436
+ """Hold the feature write lock for the complete run, including result writes."""
437
+ lock_path = root / ".devflow/planning/test-runs" / feature / ".run.lock"
438
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
439
+ handle = lock_path.open("a+")
440
+ try:
441
+ try:
442
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
443
+ except BlockingIOError:
444
+ yield False
445
+ return
446
+ yield True
447
+ finally:
448
+ try:
449
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
450
+ finally:
451
+ handle.close()
452
+
453
+
430
454
  def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
455
+ if write:
456
+ with acceptance_run_lock(root, feature) as acquired:
457
+ if not acquired:
458
+ return {"ok": False, "feature": feature, "reason": "acceptance_run_in_progress", "errors": []}
459
+ return _run_locked(root, feature, write)
460
+ return _run_locked(root, feature, write)
461
+
462
+
463
+ def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
431
464
  validation, suite, bindings = validate(root, feature)
432
465
  if not validation["ok"]:
433
466
  return validation
@@ -527,6 +560,52 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
527
560
  "errors": [] if all_passed else ["one or more frozen TC cases failed or are blocked"]}
528
561
 
529
562
 
563
+ def _report_error(value: Any) -> dict[str, Any]:
564
+ text = str(value)
565
+ # Reports are safe summaries: do not expose project or run locations from gate diagnostics.
566
+ text = re.sub(r"(?:[A-Za-z]:)?[^\s,;()]+(?:/|\\)[^\s,;()]+", "<path>", text)
567
+ text = re.sub(r"(?:^|\s)(?:/|~/?|\.\.?/)[^\s,;()]+", " <path>", text)
568
+ truncated = len(text) > MAX_REPORT_ERROR_CHARS
569
+ return {"message": text[:MAX_REPORT_ERROR_CHARS], "truncated": True} if truncated else {"message": text, "truncated": False}
570
+
571
+
572
+ def _result_summary(entry: dict[str, Any]) -> dict[str, Any]:
573
+ approval = entry.get("dsl_approval")
574
+ if isinstance(approval, dict):
575
+ approval = "approved" if approval.get("status") == "approved" else "none"
576
+ elif approval not in {"approved", "none"}:
577
+ approval = None
578
+ return {key: entry.get(key) for key in (
579
+ "tc_id", "status", "mode", "adapter_kind", "execution_surface", "error_code"
580
+ )} | {"dsl_approval": approval, "legacy_unclassified": bool(entry.get("legacy_unclassified")),
581
+ "downgrade": entry.get("downgrade")}
582
+
583
+
584
+ def report(root: Path, feature: str) -> dict[str, Any]:
585
+ _, _, result_path = paths(root, feature)
586
+ if not result_path.is_file():
587
+ return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
588
+ try:
589
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
590
+ except (OSError, json.JSONDecodeError):
591
+ return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
592
+ if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
593
+ return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
594
+ entries = [entry for entry in payload["results"] if isinstance(entry, dict)]
595
+ statuses = {status: sum(1 for entry in entries if entry.get("status") == status)
596
+ for status in ("passed", "failed", "blocked")}
597
+ counts = {"passed": statuses["passed"], "failed": statuses["failed"], "blocked": statuses["blocked"],
598
+ "legacy_unclassified": sum(bool(entry.get("legacy_unclassified")) for entry in entries),
599
+ "downgraded": sum(bool(entry.get("downgrade")) for entry in entries)}
600
+ gate = validate_results(root, feature)
601
+ raw_errors = gate.get("errors", []) if isinstance(gate, dict) else []
602
+ return {"ok": True, "feature": feature,
603
+ "gate": {"ok": bool(gate.get("ok")), "reason": gate.get("reason"),
604
+ "errors": [_report_error(error) for error in raw_errors[:MAX_REPORT_ERRORS]]},
605
+ "executed_at": payload.get("executed_at"), "test_suite": payload.get("test_suite"),
606
+ "counts": counts, "results": [_result_summary(entry) for entry in entries]}
607
+
608
+
530
609
  def run_evidence_expired(root: Path, feature: str, today: date | None = None) -> bool:
531
610
  """True only once release-closeout completed and the post-closeout retention window has elapsed.
532
611
 
@@ -764,7 +843,7 @@ def gc_runs(root: Path, feature: str, write: bool, retention_days: int = DEFAULT
764
843
 
765
844
  def main() -> int:
766
845
  parser = argparse.ArgumentParser()
767
- parser.add_argument("action", choices=("validate", "validate-human", "run", "validate-results", "gc-runs"))
846
+ parser.add_argument("action", choices=("validate", "validate-human", "run", "validate-results", "report", "gc-runs"))
768
847
  parser.add_argument("--feature", required=True)
769
848
  parser.add_argument("--write", action="store_true")
770
849
  parser.add_argument("--retention-days", type=int, default=DEFAULT_RUN_RETENTION_DAYS)
@@ -785,6 +864,8 @@ def main() -> int:
785
864
  result = run(root, args.feature, args.write) if args.write else {"ok": False, "errors": ["--write is required for run"]}
786
865
  elif args.action == "gc-runs":
787
866
  result = gc_runs(root, args.feature, args.write, max(args.retention_days, 0), max(args.unreferenced_ttl_days, 0))
867
+ elif args.action == "report":
868
+ result = report(root, args.feature)
788
869
  else:
789
870
  result = validate_results(root, args.feature)
790
871
  print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """Validate an unapproved DSL draft without making it executable."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+ from dsl_coverage import check as dsl_coverage_check
10
+ from dsl_v1 import validate as validate_dsl
11
+
12
+
13
+ MAX_INPUT_BYTES = 2 * 1024 * 1024
14
+
15
+
16
+ def main() -> int:
17
+ raw = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1)
18
+ if len(raw) > MAX_INPUT_BYTES:
19
+ result = {"ok": False, "errors": ["input exceeds 2 MiB"], "warnings": [],
20
+ "eligible_for_execution": False,
21
+ "note": "Draft validation never grants execution eligibility."}
22
+ else:
23
+ parse_error: str | None = None
24
+ try:
25
+ payload = json.loads(raw.decode("utf-8"))
26
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
27
+ payload = None
28
+ parse_error = f"input must be UTF-8 JSON: {error}"
29
+ errors: list[str] = []
30
+ if payload is None:
31
+ errors.append(parse_error or "input must be a JSON object")
32
+ elif not isinstance(payload, dict):
33
+ errors.append("input must be an object")
34
+ else:
35
+ dsl = payload.get("dsl")
36
+ tc_data = payload.get("tc_data")
37
+ if not isinstance(dsl, dict):
38
+ errors.append("dsl must be an object")
39
+ else:
40
+ errors.extend(f"DSL is invalid: {error}" for error in validate_dsl(dsl))
41
+ if not isinstance(tc_data, dict):
42
+ errors.append("tc_data must be an object")
43
+ elif isinstance(dsl, dict) and not validate_dsl(dsl):
44
+ errors.extend(f"DSL coverage: {error}" for error in dsl_coverage_check(tc_data, dsl))
45
+ result = {"ok": not errors, "errors": errors, "warnings": [],
46
+ "eligible_for_execution": False,
47
+ "note": "Draft validation never grants execution eligibility."}
48
+ print(json.dumps(result, ensure_ascii=False, indent=2))
49
+ return 0 if result["ok"] else 1
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
@@ -76,7 +76,7 @@ export default function devflowIntel(pi: ExtensionAPI) {
76
76
  pi.registerTool({
77
77
  name: "devflow_code_index",
78
78
  label: "Devflow Code Index",
79
- description: "Scan, validate, audit, or query the deterministic project code index.",
79
+ description: "Query the project code index. Choose query/relations/impact for known symbols and structural relationships; search-content only for literal source text, paths, or config values; resolve-domain only when an imported domain mapping exists. scan/validate/audit build and check the index. If the index is unavailable, stale, unsupported, or cannot answer the question, use bounded source search and report degraded index coverage.",
80
80
  parameters: z.object({
81
81
  action: z.enum(["scan", "validate", "audit", "query", "relations", "impact", "search-content", "resolve-domain", "query-plan", "domain-import"]),
82
82
  term: z.string().optional(),
@@ -17,7 +17,7 @@ description: "Use after code review to execute frozen manual TC cases, capture h
17
17
 
18
18
  1. 确认 `code-review` 已完成,测试套件已由 CP-3 冻结。
19
19
  2. 只筛选 `automation_mode: manual` 的 TC;`automated` 由后续 Core runner 执行,`blocked` 保持阻塞。
20
- 3. 人工按冻结步骤执行,逐条收集实际结果和项目内证据文件;Agent 不得代填 `passed|failed|blocked` 结论。
20
+ 3. 人工按冻结步骤执行,逐条收集实际结果和项目内证据文件;Agent 不得代填 `passed|failed|blocked` 结论。失败分析只依据 `result.json` 与 `acceptance-results.json`;`.devflow/runs/**` 下的 trace、截图、网络工件和 `runtime.log` 由用户在本地查看,Agent 不得读取(hook 会拒绝)。
21
21
  4. 本步骤不生成自动化用例,也不改变自动化 TC 的模式;需要降级为 `manual` 的自动化 TC 必须先有有效的 `mode_override` approval,随后才按人工 TC 处理。
22
22
  5. 对每个 manual TC 调用 `devflow_approval(action="propose", feature="...", kind="human_result", items=[{"subject":"TC-xxxx","evidence_files":[...]}])`。Agent 只能提交证据,不得提交 status、actual_result 或 pins。
23
23
  6. 用户在 `/devflow` → “待审批”中逐 TC 查看 oracle、步骤和证据,自行选择 `passed|failed|blocked` 并填写实际结果;只有该 UI 确认产生的 `human_result` approval 才是最终人工结果。
@@ -49,6 +49,8 @@ description: "Use after code review to execute frozen manual TC cases, capture h
49
49
 
50
50
  草稿不得作为版本通过证据。每个 manual TC 的最终 verdict 和 actual_result 只从 control.db 中仍有效的 `human_result` approval 读取;`passed` 必须固定至少一个项目内证据文件及其哈希。
51
51
 
52
+ 人工 TC 的项目内证据文件不属于 `.devflow/runs/**` Runtime 运行工件;Agent 只登记其路径并提交审批,不据其内容替用户下结论。
53
+
52
54
  ## 开发者交接
53
55
 
54
56
  写入 `.devflow/planning/test-runs/{feature}/developer-handoff.md`,至少包含:
@@ -31,7 +31,7 @@ description: "在冻结需求验收测试全部通过后,执行 Devflow 自有
31
31
 
32
32
  ## 收口动作
33
33
 
34
- 1. 调用 `devflow_run_acceptance_tests(action="validate_results", feature=...)`,让 Core 重新校验 `acceptance-results.json`(suite pins、`execution_surface` 兼容、当前仍有效的 `execution_plan`/`environment_profile`/`human_result`/`mode_override` 批准、运行目录与工件哈希);运行后 DSL 或 profile 被修改、批准被替换,都会使已记录结果失效,需要回到 `run-acceptance-tests` 重跑;不通过则停止收口。通过后读取该文件(读取即可,不得写入),计算通过/失败/阻塞数量,并展示 `legacy_unclassified`、有效降级记录和逐 TC `dsl_approval`。任何 `dsl_approval: none` 必须对应 blocked;若其被标为 passed、结果未全 passed 或存在未批准降级,立即拒绝收口。人工 TC 的 passed 只来自已确认的 `human_result` approval,`human-acceptance-results.json` 草稿不算证据。
34
+ 1. 调用 `devflow_run_acceptance_tests(action="validate_results", feature=...)`,让 Core 重新校验 `acceptance-results.json`(suite pins、`execution_surface` 兼容、当前仍有效的 `execution_plan`/`environment_profile`/`human_result`/`mode_override` 批准、运行目录与工件哈希);运行后 DSL 或 profile 被修改、批准被替换,都会使已记录结果失效,需要回到 `run-acceptance-tests` 重跑;不通过则停止收口。通过后读取该文件(读取即可,不得写入),计算通过/失败/阻塞数量,并展示 `legacy_unclassified`、有效降级记录和逐 TC `dsl_approval`。任何 `dsl_approval: none` 必须对应 blocked;若其被标为 passed、结果未全 passed 或存在未批准降级,立即拒绝收口。人工 TC 的 passed 只来自已确认的 `human_result` approval,`human-acceptance-results.json` 草稿不算证据。失败分析和证据读取只依据 `result.json` 与 `acceptance-results.json`;Agent 不得读取 trace、截图、网络工件或 `runtime.log`,这些由用户本地查看(hook 会拒绝 Agent 读取)。
35
35
  2. 更新 `.devflow/planning/navigation/INDEX.md`,记录本次 code-index revision 和验收结果 fingerprint。
36
36
  3. 在 `.devflow/planning/milestones/` 写入本版本的 ROADMAP、需求覆盖、测试结果、变更清单和已接受风险摘要。
37
37
  4. 更新 `.devflow/planning/PROJECT.md` 的当前版本与下一步目标。
@@ -48,9 +48,11 @@ description: "Execute the frozen TC acceptance suite after code review. Use the
48
48
  7. 调用 `devflow_run_acceptance_tests(action="run", feature=...)`。该 Core 工具执行 automated TC(`devflow_playwright` 在净化环境和独立运行目录中执行,并校验 Runtime 完整性与结果 nonce),合并已校验的 manual TC 结果,并记录命令/证据哈希、退出码、`dsl_approval`、`execution_surface`、`legacy_unclassified` 和降级记录。
49
49
  8. 失败、未执行或 blocked 的 TC 只能报告为 `blocked`/`failed`,不得进入版本收口。验收未全 passed 是硬门禁:即使默认 soft 模式,`run-acceptance-tests` 完成和 `release-closeout` 确认时 Core 也会重新校验结果并以 `acceptance_results_not_passed` 拒绝。可用 `devflow_run_acceptance_tests(action="validate_results", feature=...)` 预先检查。该校验会对照当前批准、DSL/profile 哈希与运行目录重新验证每条记录:运行后修改 DSL 或 profile、或批准被新的批准替换,都会使已记录的 passed 失效,必须重跑。
50
50
 
51
+ 失败分析和证据读取只依据 Runtime 的 `result.json` 与 Core 的 `acceptance-results.json`。Agent 不得读取 trace、截图、网络工件或 `runtime.log`;这些工件由用户在本地查看(hook 会拒绝 Agent 读取)。
52
+
51
53
  ## 输出
52
54
 
53
55
  - `.devflow/planning/features/{feature}/acceptance-test-bindings.json`
54
56
  - `.devflow/planning/test-runs/{feature}/acceptance-results.json`
55
57
 
56
- `acceptance-results.json` 是 Core 指定写入的本地执行缓存(Agent 不得写入,可读取用于展示);Markdown 结论不能替代它,收口以 Core 对它的重新校验(`validate_results`)为准。Hook 会拦截常规 Write/Edit/Bash 误写,但不是对同一系统用户下任意进程的安全隔离;未配置外部受保护信任根时,不得宣称结果在密码学意义上不可伪造。
58
+ `acceptance-results.json` 是 Core 指定写入的本地执行缓存(Agent 不得写入,可读取用于展示);Markdown 结论不能替代它,收口以 Core 对它的重新校验(`validate_results`)为准。失败分析和证据读取仍只使用 `result.json` 与 `acceptance-results.json`;trace、截图、网络工件和 `runtime.log` 由用户本地查看,Agent 不得读取(hook 会拒绝)。Hook 会拦截常规 Write/Edit/Bash 误写,但不是对同一系统用户下任意进程的安全隔离;未配置外部受保护信任根时,不得宣称结果在密码学意义上不可伪造。
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ function usage(): string {
16
16
  " devflow-pi init [project] [--scan]",
17
17
  " devflow-pi migrate [project] --confirm-active",
18
18
  " devflow-pi doctor [project]",
19
+ " devflow-pi mcp --client <name> [--run-timeout-seconds <seconds>]",
19
20
  " devflow-pi usage [project]",
20
21
  " devflow-pi [project] [--resume [session-id]] [--prompt <text>] [--mcp] [--yolo]",
21
22
  ].join("\n");
@@ -80,6 +81,15 @@ async function main(): Promise<void> {
80
81
  console.log(JSON.stringify({ status: "migrated", root, migrated }, null, 2));
81
82
  return;
82
83
  }
84
+ if (command === "mcp") {
85
+ const client = option(args, "--client");
86
+ if (!client) throw new Error("devflow-pi mcp requires --client <name>");
87
+ const timeout = option(args, "--run-timeout-seconds");
88
+ if (timeout !== undefined && (!Number.isFinite(Number(timeout)) || Number(timeout) <= 0 || Number(timeout) > 7200)) throw new Error("--run-timeout-seconds must be between 1 and 7200");
89
+ const { startMcpServer } = await import("./mcp/server.ts");
90
+ await startMcpServer({ client, runTimeoutSeconds: timeout === undefined ? undefined : Number(timeout) });
91
+ return;
92
+ }
83
93
  const positionalProject = command && !["doctor", "usage"].includes(command) && !command.startsWith("-") ? command : undefined;
84
94
  const requestedProject = ["doctor", "usage"].includes(command || "") ? args[1] : positionalProject;
85
95
  const root = await resolveProjectRoot(process.cwd(), { explicit: requestedProject });
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.95";
2
+ export const PRODUCT_VERSION = "0.1.96";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5
 
package/src/doctor.ts CHANGED
@@ -316,6 +316,15 @@ export async function runHostDoctor(project: ProjectContext, options: DoctorOpti
316
316
  add("agent-dir", true, devflowAgentDir());
317
317
  add("session-isolation", true, projectSessionDir(project.manifest.projectId));
318
318
  add("devflow-home", true, devflowHome());
319
+ const mcpConfig = path.join(devflowHome(), "mcp.json");
320
+ if (!(await exists(mcpConfig))) add("mcp", true, "MCP 未配置");
321
+ else {
322
+ try {
323
+ const config = JSON.parse(await readFile(mcpConfig, "utf8")) as Record<string, unknown>;
324
+ const valid = config.schema_version === 1 && config.clients !== null && typeof config.clients === "object" && !Array.isArray(config.clients);
325
+ add("mcp", valid, valid ? `${mcpConfig} (schema_version 1)` : `${mcpConfig}: schema_version must be 1 and clients must be an object`);
326
+ } catch (error) { add("mcp", false, `${mcpConfig}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`); }
327
+ }
319
328
  return { ok: checks.every(check => check.ok), checks };
320
329
  }
321
330
 
@@ -0,0 +1,103 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { normalizeDevflowHome } from "../paths.ts";
6
+ import type { McpToolName } from "./types.ts";
7
+
8
+ export const FEATURE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
+ export const TOOLS = ["validate_feature", "run_feature", "get_feature_report", "dsl_draft_validate"] as const;
10
+ export const MAX_DSL_BYTES = 2 * 1024 * 1024;
11
+ const PERMISSIONS = new Set(["validate", "report", "run", "dsl_draft"]);
12
+
13
+ export type ProjectGrant = { features: string[] | "*"; permissions: string[]; environment_profiles: string[] };
14
+ export type McpConfig = { schema_version: 1; clients: Record<string, { projects: Record<string, ProjectGrant> }> };
15
+
16
+ export class McpError extends Error {
17
+ constructor(public readonly code: string, message: string) { super(message); }
18
+ }
19
+
20
+ function invalidConfig(message: string): never {
21
+ throw new McpError("mcp_permission_denied", `invalid mcp.json: ${message}`);
22
+ }
23
+
24
+ function stringArray(value: unknown, label: string): string[] {
25
+ if (!Array.isArray(value) || value.some(item => typeof item !== "string")) invalidConfig(`${label} must be an array of strings`);
26
+ return value as string[];
27
+ }
28
+
29
+ function validateGrant(value: unknown, label: string): ProjectGrant {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalidConfig(`${label} must be an object`);
31
+ const grant = value as Record<string, unknown>;
32
+ const features = grant.features === "*" ? "*" : stringArray(grant.features, `${label}.features`);
33
+ const permissions = stringArray(grant.permissions, `${label}.permissions`);
34
+ if (permissions.some(permission => !PERMISSIONS.has(permission))) invalidConfig(`${label}.permissions contains an unsupported permission`);
35
+ const environmentProfiles = grant.environment_profiles === undefined ? [] : stringArray(grant.environment_profiles, `${label}.environment_profiles`);
36
+ return { features, permissions, environment_profiles: environmentProfiles };
37
+ }
38
+
39
+ function validateConfig(value: unknown): McpConfig {
40
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalidConfig("root must be an object");
41
+ const root = value as Record<string, unknown>;
42
+ if (root.schema_version !== 1) invalidConfig("schema_version must be 1");
43
+ if (!root.clients || typeof root.clients !== "object" || Array.isArray(root.clients)) invalidConfig("clients must be an object");
44
+ const clients: McpConfig["clients"] = {};
45
+ for (const [clientName, clientValue] of Object.entries(root.clients as Record<string, unknown>)) {
46
+ if (!clientValue || typeof clientValue !== "object" || Array.isArray(clientValue)) invalidConfig(`clients.${clientName} must be an object`);
47
+ const projectsValue = (clientValue as Record<string, unknown>).projects;
48
+ if (!projectsValue || typeof projectsValue !== "object" || Array.isArray(projectsValue)) invalidConfig(`clients.${clientName}.projects must be an object`);
49
+ const projects: Record<string, ProjectGrant> = {};
50
+ for (const [project, grant] of Object.entries(projectsValue as Record<string, unknown>)) {
51
+ if (!path.isAbsolute(project)) invalidConfig(`project key must be absolute: ${project}`);
52
+ projects[project] = validateGrant(grant, `clients.${clientName}.projects.${project}`);
53
+ }
54
+ clients[clientName] = { projects };
55
+ }
56
+ return { schema_version: 1, clients };
57
+ }
58
+
59
+ export async function loadAuthorization(client: string, home = normalizeDevflowHome(process.env.DEVFLOW_HOME)): Promise<McpConfig> {
60
+ const file = path.join(home, "mcp.json");
61
+ let value: unknown;
62
+ try {
63
+ value = JSON.parse(await readFile(file, "utf8"));
64
+ } catch (error) {
65
+ throw new McpError("mcp_permission_denied", `MCP authorization is unavailable: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+ const config = validateConfig(value);
68
+ if (!Object.prototype.hasOwnProperty.call(config.clients, client)) throw new McpError("mcp_permission_denied", `unknown MCP client: ${client}`);
69
+ return config;
70
+ }
71
+
72
+ function permissionFor(tool: McpToolName): string {
73
+ if (tool === "dsl_draft_validate") return "dsl_draft";
74
+ if (tool === "get_feature_report") return "report";
75
+ if (tool === "validate_feature") return "validate";
76
+ return "run";
77
+ }
78
+
79
+ export async function authorize(config: McpConfig, client: string, tool: McpToolName, project: unknown, feature?: unknown): Promise<{ project: string; grant: ProjectGrant }> {
80
+ if (typeof project !== "string" || !path.isAbsolute(project)) throw new McpError("mcp_input_invalid", "project must be an absolute path");
81
+ let resolved: string;
82
+ try { resolved = await realpath(project); } catch { throw new McpError("mcp_project_not_allowed", "project does not exist"); }
83
+ const grants = config.clients[client]?.projects ?? {};
84
+ let grant: ProjectGrant | undefined;
85
+ for (const [key, candidate] of Object.entries(grants)) {
86
+ try {
87
+ if (await realpath(key) === resolved) { grant = candidate; break; }
88
+ } catch {
89
+ // A missing configured project cannot authorize a request.
90
+ }
91
+ }
92
+ if (!grant) throw new McpError("mcp_project_not_allowed", "project is not authorized");
93
+ if (tool !== "dsl_draft_validate") {
94
+ if (typeof feature !== "string" || !FEATURE_RE.test(feature)) throw new McpError("mcp_input_invalid", "feature is invalid");
95
+ if (grant.features !== "*" && !grant.features.includes(feature)) throw new McpError("mcp_feature_not_allowed", "feature is not authorized");
96
+ }
97
+ if (!grant.permissions.includes(permissionFor(tool))) throw new McpError("mcp_permission_denied", "permission is not authorized");
98
+ return { project: resolved, grant };
99
+ }
100
+
101
+ export function requestHash(value: unknown): string {
102
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
103
+ }
@@ -0,0 +1,102 @@
1
+ import path from "node:path";
2
+
3
+ import { packageRoot } from "../paths.ts";
4
+ import type { CoreRunner, CoreResult } from "./types.ts";
5
+
6
+ export const runnerScript = path.join(packageRoot(), "plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py");
7
+ export const dslScript = path.join(packageRoot(), "plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py");
8
+
9
+ const DEFAULT_TIMEOUT_MS = 7_200_000;
10
+ const TERMINATION_GRACE_MS = 5_000;
11
+ const PROGRESS_INTERVAL_MS = 10_000;
12
+
13
+ function terminateProcessGroup(pid: number | undefined, signal: "SIGTERM" | "SIGKILL"): void {
14
+ if (pid === undefined) return;
15
+ try { process.kill(-pid, signal); } catch { /* process group may already be gone */ }
16
+ }
17
+
18
+ async function settleText(value: Promise<string>, timeoutMs: number): Promise<string> {
19
+ return Promise.race([
20
+ value,
21
+ new Promise<string>(resolve => setTimeout(() => resolve(""), timeoutMs)),
22
+ ]);
23
+ }
24
+
25
+ export const runPython: CoreRunner = async (script, args, cwd, stdin, timeoutMs = DEFAULT_TIMEOUT_MS, onProgress) => {
26
+ // -E/-s ignore PYTHON* variables and user site-packages. Not -I: isolated mode also drops the
27
+ // script directory from sys.path, and the Core scripts import their sibling modules.
28
+ const child = Bun.spawn(["python3", "-E", "-s", script, ...args], {
29
+ cwd,
30
+ detached: true,
31
+ stdin: "pipe",
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ });
35
+ const stdoutPromise = new Response(child.stdout).text();
36
+ const stderrPromise = new Response(child.stderr).text();
37
+ if (stdin !== undefined) child.stdin.write(stdin);
38
+ child.stdin.end();
39
+ const progressTimer = onProgress === undefined ? undefined : setInterval(() => {
40
+ void Promise.resolve(onProgress()).catch(() => undefined);
41
+ }, PROGRESS_INTERVAL_MS);
42
+
43
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
44
+ let timedOut = false;
45
+ const timeoutPromise = new Promise<"timeout">(resolve => {
46
+ timeoutHandle = setTimeout(() => resolve("timeout"), timeoutMs);
47
+ });
48
+ const exitPromise = child.exited.then(exitCode => ({ exitCode }));
49
+ const winner = await Promise.race([exitPromise, timeoutPromise]);
50
+ if (winner === "timeout") {
51
+ timedOut = true;
52
+ terminateProcessGroup(child.pid, "SIGTERM");
53
+ try { child.kill("SIGTERM"); } catch { /* already exited */ }
54
+ await new Promise(resolve => setTimeout(resolve, TERMINATION_GRACE_MS));
55
+ terminateProcessGroup(child.pid, "SIGKILL");
56
+ try { child.kill("SIGKILL"); } catch { /* already exited */ }
57
+ if (progressTimer !== undefined) clearInterval(progressTimer);
58
+ clearTimeout(timeoutHandle);
59
+ return {
60
+ exitCode: null,
61
+ stdout: await settleText(stdoutPromise, 100),
62
+ stderr: await settleText(stderrPromise, 100),
63
+ timedOut,
64
+ };
65
+ }
66
+
67
+ if (progressTimer !== undefined) clearInterval(progressTimer);
68
+ clearTimeout(timeoutHandle);
69
+ return {
70
+ exitCode: winner.exitCode,
71
+ stdout: await stdoutPromise,
72
+ stderr: await stderrPromise,
73
+ timedOut,
74
+ };
75
+ };
76
+
77
+ export async function invokeCore(
78
+ runner: CoreRunner,
79
+ action: string,
80
+ project: string,
81
+ feature: string,
82
+ timeoutMs?: number,
83
+ onProgress?: () => void | Promise<void>,
84
+ ): Promise<CoreResult> {
85
+ const result = await runner(
86
+ runnerScript,
87
+ [action, "--feature", feature, "--json", ...(action === "run" ? ["--write"] : [])],
88
+ project,
89
+ undefined,
90
+ timeoutMs,
91
+ onProgress,
92
+ );
93
+ if (result.timedOut) return { ok: false, reason: "mcp_run_timeout", error_code: "mcp_run_timeout" };
94
+ try {
95
+ return JSON.parse(result.stdout) as CoreResult;
96
+ } catch {
97
+ // Keep the tail of the diagnostic (where Python tracebacks end), without project paths.
98
+ const detail = (result.stderr || result.stdout || `Core exited ${result.exitCode}`).slice(-1000)
99
+ .replace(/(?:[A-Za-z]:)?[^\s,;()"']+[\/][^\s,;()"']+/g, "<path>");
100
+ return { ok: false, reason: "core_failed", error_code: "core_failed", errors: [detail] };
101
+ }
102
+ }
@@ -0,0 +1,193 @@
1
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+
8
+ import { devflowHome } from "../paths.ts";
9
+ import { authorize, FEATURE_RE, loadAuthorization, MAX_DSL_BYTES, McpError, requestHash } from "./auth.ts";
10
+ import { dslScript, invokeCore, runPython } from "./core.ts";
11
+ import type { CoreRunner, McpServerOptions, McpToolName } from "./types.ts";
12
+
13
+ const RESPONSE_LIMIT = 64 * 1024;
14
+
15
+ type ProgressCallback = () => void | Promise<void>;
16
+
17
+ function bounded(value: unknown): string {
18
+ const text = JSON.stringify(value);
19
+ if (text.length <= RESPONSE_LIMIT) return text;
20
+ return JSON.stringify({
21
+ ok: false,
22
+ reason: "response_truncated",
23
+ truncated: true,
24
+ output: text.slice(0, RESPONSE_LIMIT - 80),
25
+ });
26
+ }
27
+
28
+ function toolResult(value: unknown) {
29
+ return {
30
+ content: [{ type: "text" as const, text: bounded(value) }],
31
+ isError: Boolean(value && typeof value === "object" && (value as { ok?: unknown }).ok === false),
32
+ };
33
+ }
34
+
35
+ async function audit(home: string, row: Record<string, unknown>): Promise<void> {
36
+ const directory = path.join(home, "mcp-audit");
37
+ await mkdir(directory, { recursive: true, mode: 0o700 });
38
+ const file = path.join(directory, `${new Date().toISOString().slice(0, 7)}.jsonl`);
39
+ await appendFile(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
40
+ }
41
+
42
+ function collectEnvironmentProfiles(value: unknown): Set<string> {
43
+ const profiles = new Set<string>();
44
+ const visit = (current: unknown): void => {
45
+ if (Array.isArray(current)) {
46
+ current.forEach(visit);
47
+ return;
48
+ }
49
+ if (!current || typeof current !== "object") return;
50
+ const record = current as Record<string, unknown>;
51
+ if ((record.kind === "devflow_playwright" || record.adapter_kind === "devflow_playwright") && typeof record.environment_profile === "string") {
52
+ profiles.add(record.environment_profile);
53
+ }
54
+ Object.values(record).forEach(visit);
55
+ };
56
+ visit(value);
57
+ return profiles;
58
+ }
59
+
60
+ const MAX_FAILURE_ERRORS = 50;
61
+ const MAX_FAILURE_ERROR_CHARS = 500;
62
+
63
+ // Failure details must not expose project or run locations (same rule as the Core report).
64
+ function scrubPaths(text: string): string {
65
+ return text
66
+ .replace(/(?:[A-Za-z]:)?[^\s,;()"']+[\/][^\s,;()"']+/g, "<path>")
67
+ .slice(0, MAX_FAILURE_ERROR_CHARS);
68
+ }
69
+
70
+ // A run that executed TCs always answers with the Core report, even when TCs failed or are
71
+ // blocked: the report's gate says whether acceptance passed. Only runs that never produced
72
+ // results (lock held, invalid bindings/approvals, Core crash) short-circuit with an error.
73
+ function runFailure(value: Record<string, unknown>): Record<string, unknown> {
74
+ const code = typeof value.error_code === "string" ? value.error_code
75
+ : typeof value.reason === "string" && value.reason ? value.reason
76
+ : Array.isArray(value.errors) && value.errors.length > 0 ? "acceptance_validation_failed"
77
+ : "core_failed";
78
+ const errors = Array.isArray(value.errors) ? value.errors.slice(0, MAX_FAILURE_ERRORS).map(item => scrubPaths(String(item))) : [];
79
+ return { ok: false, error_code: code, reason: code, errors };
80
+ }
81
+
82
+ export async function createMcpServer(options: McpServerOptions) {
83
+ const home = options.home ?? devflowHome();
84
+ const config = await loadAuthorization(options.client, home);
85
+ const runner: CoreRunner = options.runner ?? runPython;
86
+ let running = false;
87
+
88
+ const server = new McpServer({ name: "devflow-acceptance", version: "1.0.0" });
89
+
90
+ const call = async (tool: McpToolName, args: Record<string, unknown>, onProgress?: ProgressCallback): Promise<Record<string, unknown>> => {
91
+ const started = Date.now();
92
+ let ok = false;
93
+ let reason: string | null = null;
94
+ try {
95
+ if (tool === "dsl_draft_validate") {
96
+ const dslText = JSON.stringify(args.dsl);
97
+ if (typeof dslText !== "string" || Buffer.byteLength(dslText) > MAX_DSL_BYTES) {
98
+ throw new McpError("mcp_input_invalid", "DSL exceeds 2 MiB or is not JSON");
99
+ }
100
+ const auth = await authorize(config, options.client, tool, args.project ?? process.cwd());
101
+ const raw = await runner(dslScript, [], auth.project, JSON.stringify({ dsl: args.dsl, tc_data: args.tc_data }));
102
+ if (raw.timedOut) return { ok: false, error_code: "core_failed", reason: "core_failed" };
103
+ try {
104
+ const parsed = JSON.parse(raw.stdout) as Record<string, unknown>;
105
+ ok = parsed.ok === true;
106
+ return parsed;
107
+ } catch {
108
+ return { ok: false, error_code: "core_failed", reason: raw.stderr || raw.stdout };
109
+ }
110
+ }
111
+
112
+ const feature = args.feature;
113
+ const auth = await authorize(config, options.client, tool, args.project, feature);
114
+ if (tool === "run_feature" && running) throw new McpError("mcp_busy", "another run_feature is in progress");
115
+
116
+ if (tool === "run_feature") {
117
+ const bindingFile = path.join(auth.project, ".devflow/planning/features", String(feature), "acceptance-test-bindings.json");
118
+ let bindings: unknown;
119
+ try {
120
+ bindings = JSON.parse(await readFile(bindingFile, "utf8"));
121
+ } catch {
122
+ throw new McpError("mcp_input_invalid", "acceptance-test-bindings.json is missing or invalid");
123
+ }
124
+ const profiles = collectEnvironmentProfiles(bindings);
125
+ if ([...profiles].some(profile => !auth.grant.environment_profiles.includes(profile))) {
126
+ throw new McpError("mcp_environment_not_allowed", "environment profile is not authorized");
127
+ }
128
+
129
+ running = true;
130
+ try {
131
+ const runResult = await invokeCore(
132
+ runner,
133
+ "run",
134
+ auth.project,
135
+ String(feature),
136
+ Math.min(7_200_000, Math.max(1, options.runTimeoutSeconds ?? 7200) * 1000),
137
+ onProgress,
138
+ );
139
+ if (!Array.isArray(runResult.results)) return runFailure(runResult);
140
+ const report = await invokeCore(runner, "report", auth.project, String(feature));
141
+ ok = report.ok === true;
142
+ return report;
143
+ } finally {
144
+ running = false;
145
+ }
146
+ }
147
+
148
+ const coreResult = await invokeCore(runner, tool === "validate_feature" ? "validate" : "report", auth.project, String(feature));
149
+ ok = coreResult.ok === true;
150
+ return coreResult;
151
+ } catch (error) {
152
+ const mcpError = error instanceof McpError
153
+ ? error
154
+ : new McpError("core_failed", error instanceof Error ? error.message : String(error));
155
+ reason = mcpError.code;
156
+ return { ok: false, error_code: mcpError.code, reason: mcpError.message };
157
+ } finally {
158
+ await audit(home, {
159
+ ts: new Date().toISOString(),
160
+ client: options.client,
161
+ project_id: typeof args.project === "string" ? requestHash(path.resolve(args.project)) : null,
162
+ tool,
163
+ feature: args.feature ?? null,
164
+ ok,
165
+ reason,
166
+ duration_ms: Date.now() - started,
167
+ request_sha256: requestHash(args),
168
+ });
169
+ }
170
+ };
171
+
172
+ const input = z.object({ project: z.string(), feature: z.string().regex(FEATURE_RE) });
173
+ const withProgress = (tool: McpToolName) => (args: any, extra: any) => {
174
+ const token = extra._meta?.progressToken;
175
+ const progress = token === undefined
176
+ ? undefined
177
+ : () => extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: Date.now(), message: "run_feature is still running" } });
178
+ return call(tool, args, progress).then(toolResult);
179
+ };
180
+
181
+ server.registerTool("validate_feature", { description: "Validate a feature; no artifacts are returned.", inputSchema: input.shape }, withProgress("validate_feature"));
182
+ server.registerTool("run_feature", { description: "Run a feature, then return only the Core report. Approval is never confirmed here; blocked tests require /devflow.", inputSchema: input.shape }, withProgress("run_feature"));
183
+ server.registerTool("get_feature_report", { description: "Get the Core feature report; no artifacts are returned.", inputSchema: input.shape }, withProgress("get_feature_report"));
184
+ server.registerTool("dsl_draft_validate", { description: "Validate DSL schema and coverage only; this does not make the draft executable and does not check approvals or manifests.", inputSchema: { project: z.string(), dsl: z.unknown(), tc_data: z.unknown() } }, withProgress("dsl_draft_validate"));
185
+
186
+ Object.assign(server, { invoke: call, boundedForTest: bounded });
187
+ return server;
188
+ }
189
+
190
+ export async function startMcpServer(options: McpServerOptions): Promise<void> {
191
+ const server = await createMcpServer(options);
192
+ await server.connect(new StdioServerTransport());
193
+ }
@@ -0,0 +1,5 @@
1
+ export type McpToolName = "validate_feature" | "run_feature" | "get_feature_report" | "dsl_draft_validate";
2
+ export type CoreResult = { ok: boolean; [key: string]: unknown };
3
+ export type CoreRunnerResult = { exitCode: number | null; stdout: string; stderr: string; timedOut?: boolean };
4
+ export type CoreRunner = (script: string, args: string[], cwd: string, stdin?: string, timeoutMs?: number, onProgress?: () => void | Promise<void>) => Promise<CoreRunnerResult>;
5
+ export type McpServerOptions = { client: string; runTimeoutSeconds?: number; runner?: CoreRunner; home?: string };