@guanlin4924/devflow-pi 0.1.97 → 0.1.99

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 (25) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +591 -274
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
  13. package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
  14. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +264 -101
  15. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
  16. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
  17. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
  18. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +3 -0
  19. package/src/constants.ts +1 -1
  20. package/src/mcp/core.ts +23 -24
  21. package/src/mcp/server.ts +11 -12
  22. package/src/project-context.ts +9 -1
  23. package/src/project-initializer.ts +4 -4
  24. package/src/session-rollover.ts +10 -0
  25. package/src/workflow-diagnosis.ts +407 -0
@@ -766,10 +766,15 @@ def validate_results(root: Path, feature: str, today: date | None = None) -> dic
766
766
  errors.append("acceptance-test results are pinned to a stale TC suite")
767
767
  expected = set(validation.get("tc_ids", []))
768
768
  result_entries = results.get("results") if isinstance(results.get("results"), list) else []
769
+ if any(not isinstance(item, dict) or not isinstance(item.get("tc_id"), str) for item in result_entries):
770
+ errors.append("acceptance-test result entries must be objects with a string tc_id")
769
771
  actual = {item.get("tc_id") for item in result_entries if isinstance(item, dict) and isinstance(item.get("tc_id"), str)}
770
772
  if actual != expected:
771
773
  errors.append("acceptance-test results do not cover exactly the frozen TC suite")
772
- failed = sorted(item.get("tc_id", "<unknown>") for item in result_entries if not isinstance(item, dict) or item.get("status") != "passed")
774
+ failed = sorted(
775
+ item["tc_id"] if isinstance(item, dict) and isinstance(item.get("tc_id"), str) else "<unknown>"
776
+ for item in result_entries if not isinstance(item, dict) or item.get("status") != "passed"
777
+ )
773
778
  if failed:
774
779
  errors.append(f"frozen TC cases not passed: {', '.join(failed)}")
775
780
  if suite is not None and isinstance(bindings, dict):
@@ -8,6 +8,8 @@ import subprocess
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from business_traceability import confirmed_requirement_ids
12
+
11
13
 
12
14
  def load_yaml(path: Path) -> Any:
13
15
  try:
@@ -36,7 +38,7 @@ def main() -> int:
36
38
  errors = []
37
39
  warnings = []
38
40
  actions = {item.get("id") for item in business.get("actions", []) if isinstance(item, dict)}
39
- requirements = {item.get("id") for item in traceability.get("requirements", []) if isinstance(item, dict) and item.get("status") in {"confirmed", "approved"}} if isinstance(traceability, dict) else set()
41
+ requirements = confirmed_requirement_ids(traceability)
40
42
  values = claims.get("claims") if isinstance(claims, dict) else claims
41
43
  if not isinstance(values, list):
42
44
  errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": "claims", "message": "claims must be an array"})
@@ -8,6 +8,8 @@ import subprocess
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from business_traceability import confirmed_requirement_ids
12
+
11
13
 
12
14
  def read_json(path: Path) -> Any:
13
15
  return json.loads(path.read_text(encoding="utf-8"))
@@ -41,7 +43,7 @@ def main() -> int:
41
43
  requirements = set()
42
44
  if args.traceability:
43
45
  traceability = read_json(args.traceability)
44
- requirements = {item.get("id") for item in traceability.get("requirements", []) if isinstance(item, dict) and item.get("status") in {"confirmed", "approved"}}
46
+ requirements = confirmed_requirement_ids(traceability)
45
47
  if args.wireframe:
46
48
  wireframe = read_json(args.wireframe)
47
49
  for screen in wireframe.get("screens", []):
@@ -0,0 +1,31 @@
1
+ """Read confirmed requirement references from Core graph views and legacy lists."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+
7
+ def confirmed_requirement_ids(traceability: Any) -> set[str]:
8
+ if not isinstance(traceability, dict):
9
+ return set()
10
+ meta = traceability.get("view_meta", {})
11
+ if isinstance(meta, dict) and (meta.get("state") == "stale" or meta.get("needs_recheck") is True):
12
+ return set()
13
+ stale_ids = {
14
+ entry.get("node_id") if isinstance(entry, dict) else entry
15
+ for entry in traceability.get("stale", [])
16
+ if isinstance(entry, (dict, str))
17
+ }
18
+ # The graph's typed nodes are authoritative when present; a legacy list
19
+ # must not override their confirmation state or introduce other node kinds.
20
+ graph_view = "nodes" in traceability
21
+ items = traceability.get("nodes" if graph_view else "requirements", [])
22
+ if not isinstance(items, list):
23
+ return set()
24
+ return {
25
+ item["id"] for item in items
26
+ if isinstance(item, dict)
27
+ and isinstance(item.get("id"), str)
28
+ and (not graph_view or item.get("kind") == "requirement")
29
+ and item.get("status") in {"confirmed", "approved"}
30
+ and item["id"] not in stale_ids
31
+ }
@@ -255,11 +255,14 @@ def semantic_errors(dsl: dict[str, Any]) -> list[str]:
255
255
  for name, spec in variables.items():
256
256
  if name in BUILTIN_VARIABLES:
257
257
  errors.append(f"$.variables.{name} redefines a built-in variable")
258
+ if isinstance(spec.get("value"), str) and VARIABLE_REF_RE.search(spec["value"]):
259
+ errors.append(f"$.variables.{name} variable values must be literals")
258
260
  if "secret" in spec or spec.get("sensitive") is True:
259
261
  sensitive.add(name)
260
- known = set(variables) | {"execution_id"}
261
- _references(dsl["data"]["namespace"], "$.data.namespace", known, errors)
262
- known.add("namespace")
262
+ # Runtime initializes namespace with execution_id only; declared values are
263
+ # literals, not expressions evaluated against other variables.
264
+ _references(dsl["data"]["namespace"], "$.data.namespace", {"execution_id"}, errors)
265
+ known = set(variables) | BUILTIN_VARIABLES
263
266
  seen_ids: dict[str, str] = {}
264
267
  for _phase, path, step in all_steps(dsl):
265
268
  step_id = step["id"]
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import argparse
10
10
  import hashlib
11
+ import ipaddress
11
12
  import json
12
13
  import re
13
14
  from pathlib import Path
@@ -35,7 +36,15 @@ def profile_path(root: Path, name: str) -> Path:
35
36
 
36
37
 
37
38
  def origin_of(url: str) -> str | None:
38
- parts = urlsplit(url)
39
+ try:
40
+ parts = urlsplit(url)
41
+ # urlsplit validates ports lazily; older Python versions also accept
42
+ # malformed bracketed IPv6 hosts that the browser rejects.
43
+ _ = parts.port
44
+ if parts.hostname and ":" in parts.hostname:
45
+ ipaddress.IPv6Address(parts.hostname)
46
+ except ValueError:
47
+ return None
39
48
  if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password:
40
49
  return None
41
50
  origin = f"{parts.scheme}://{parts.netloc}"
@@ -48,7 +57,7 @@ def _origins(value: Any, label: str, errors: list[str], required: bool) -> list[
48
57
  return []
49
58
  origins: list[str] = []
50
59
  for index, item in enumerate(value):
51
- if not isinstance(item, str) or not ORIGIN_RE.fullmatch(item):
60
+ if not isinstance(item, str) or not ORIGIN_RE.fullmatch(item) or origin_of(item) is None:
52
61
  errors.append(f"{label}[{index}] must be an exact http(s) origin without path, wildcard, or credentials")
53
62
  elif item in origins:
54
63
  errors.append(f"{label}[{index}] duplicates {item}")
@@ -75,7 +75,7 @@ module ParallelRun
75
75
  match && match[1]
76
76
  end
77
77
 
78
- def attach_state_ref(root, state_path, run_id, expected_revision)
78
+ def prepare_state_ref(root, state_path, run_id, expected_revision)
79
79
  absolute, = DevflowControlIO.resolve_control_path(root, state_path, :state)
80
80
  original = File.read(absolute)
81
81
  front = state_front_matter(original)
@@ -90,6 +90,11 @@ module ParallelRun
90
90
  state["parallel_run_ref"] = run_id
91
91
  state["revision"] = actual + 1
92
92
  body = original.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
93
+ [absolute, body, state]
94
+ end
95
+
96
+ def attach_state_ref(root, state_path, run_id, expected_revision)
97
+ absolute, body, state = prepare_state_ref(root, state_path, run_id, expected_revision)
93
98
  DevflowControlIO.atomic_write(absolute, body)
94
99
  state
95
100
  end
@@ -131,11 +136,13 @@ module ParallelRun
131
136
  "created_at" => Time.now.utc.iso8601,
132
137
  "branches" => branch_records,
133
138
  }
134
- write(root, feature, record)
135
- updated_state = attach_state_ref(root, state_path, run_id, state_revision)
139
+ # Validate the STATE CAS and reference before persisting scheduler state.
140
+ # A rejected prepare must not leave a run that blocks a corrected retry.
141
+ absolute, body, updated_state = prepare_state_ref(root, state_path, run_id, state_revision)
136
142
  record["bound_state_revision"] = Integer(updated_state.fetch("revision"))
137
143
  record["branches"].each { |branch| branch["expected_state_revision"] = record["bound_state_revision"] }
138
144
  write(root, feature, record)
145
+ DevflowControlIO.atomic_write(absolute, body)
139
146
  { "ok" => true, "run" => record }
140
147
  ensure
141
148
  DevflowControlIO.release_lock(lock)
@@ -185,12 +192,14 @@ module ParallelRun
185
192
 
186
193
  previous_id = branch["branch_id"]
187
194
  branch["history"] ||= []
188
- branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path")
195
+ branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path", "output_fingerprint", "error")
189
196
  branch["attempt"] = Integer(branch.fetch("attempt", 0)) + 1
190
197
  branch["branch_revision"] = 0
191
198
  branch["previous_branch_id"] = previous_id
192
199
  branch["status"] = "pending"
193
- branch.delete("error")
200
+ # Task ownership and evidence belong to the previous attempt. Keeping
201
+ # them on a pending retry makes inspect expose stale execution results.
202
+ %w[task_id evidence_path output_fingerprint error].each { |key| branch.delete(key) }
194
203
  record["status"] = "running"
195
204
  record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
196
205
  record["updated_at"] = Time.now.utc.iso8601
@@ -107,9 +107,14 @@ def previous_step_payload(root, state)
107
107
  outputs = paths.each_with_object([]) do |raw_path, result|
108
108
  relative = render.call(raw_path)
109
109
  absolute = File.expand_path(relative, root)
110
- next unless absolute.start_with?("#{File.realpath(root)}#{File::SEPARATOR}") && File.exist?(absolute) && File.lstat(absolute).file?
111
-
112
- result << { "path" => relative, "sha256" => Digest::SHA256.hexdigest(File.binread(absolute)) }
110
+ # Keep required paths even when their files disappeared before prepare.
111
+ # Otherwise startup_preflight cannot distinguish missing evidence from a
112
+ # previous step that had no output obligations at all.
113
+ output = { "path" => relative, "sha256" => nil }
114
+ if absolute.start_with?("#{File.realpath(root)}#{File::SEPARATOR}") && File.exist?(absolute) && File.lstat(absolute).file? && File.realpath(absolute).start_with?("#{File.realpath(root)}#{File::SEPARATOR}")
115
+ output["sha256"] = Digest::SHA256.hexdigest(File.binread(absolute))
116
+ end
117
+ result << output
113
118
  end
114
119
  { "id" => step_id, "label" => step["label"].to_s, "outputs" => outputs }
115
120
  rescue StandardError
@@ -156,7 +161,8 @@ result = catch(:transition_done) do
156
161
  # is safe only when it belongs to this exact STATE revision and its
157
162
  # content hash still verifies.
158
163
  expected_state_revision = rollover["expected_state_revision"]
159
- if expected_state_revision == revision || (expected_state_revision.is_a?(Integer) && expected_state_revision + 1 == revision)
164
+ prepared_revision = rollover.fetch("prepared_state_revision", expected_state_revision.is_a?(Integer) ? expected_state_revision + 1 : nil)
165
+ if expected_state_revision == revision || prepared_revision == revision
160
166
  existing_path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
161
167
  if File.exist?(existing_path) && Digest::SHA256.hexdigest(File.binread(existing_path)) == rollover["handoff_sha256"]
162
168
  result = { "ok" => true, "written" => false, "reused" => true, "rollover" => rollover, "revision" => revision }
@@ -216,7 +222,7 @@ result = catch(:transition_done) do
216
222
  state["rollover"] = {
217
223
  "id" => rollover_id, "status" => "prepared", "handoff_path" => relative_path,
218
224
  "handoff_sha256" => digest, "source_session_id" => options[:source_session_id],
219
- "target_session_id" => nil, "expected_state_revision" => revision,
225
+ "target_session_id" => nil, "expected_state_revision" => revision, "prepared_state_revision" => revision + 1,
220
226
  "prepared_at" => iso_now, "claimed_at" => nil
221
227
  }
222
228
  state["revision"] = revision + 1
@@ -284,6 +290,7 @@ result = catch(:transition_done) do
284
290
  throw :transition_done, result
285
291
  end
286
292
  if options[:write]
293
+ rollover["prepared_state_revision"] = revision + 1
287
294
  rollover["status"] = "prepared"; rollover["target_session_id"] = nil; rollover["claimed_at"] = nil; rollover.delete("lease_expires_at"); rollover.delete("last_renewed_at")
288
295
  state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
289
296
  end
@@ -125,6 +125,14 @@ def startup_preflight(root: Path, feature: str, step: str,
125
125
  except ValueError:
126
126
  exists = False
127
127
  input_facts.append({"path": rendered, "required": required, "exists": exists})
128
+ # Input readiness is independent of whether the caller supplied
129
+ # a frozen contract. Keep the derived contract view exempt, as
130
+ # review_preflight does, and avoid duplicating its diagnostics.
131
+ if (required and not exists
132
+ and rendered != ".devflow/planning/execution-contract.md"
133
+ and not any(issue.get("code") == "MISSING_INPUT_FACT"
134
+ and issue.get("path") == rendered for issue in issues)):
135
+ issues.append({"code": "MISSING_INPUT_FACT", "path": rendered})
128
136
  facts["inputs"] = input_facts
129
137
  facts["outputs"] = [
130
138
  render_path(item["path"], feature, phase)
@@ -9,6 +9,14 @@ type ProcessResult = { ok: boolean; exitCode: number; stdout: string; stderr: st
9
9
 
10
10
  function resolveSkillRoot(cwd: string): string {
11
11
  const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
12
+ if (process.env.DEVFLOW_RUNTIME_MODE === "embedded") {
13
+ const pluginRoot = path.resolve(process.env.DEVFLOW_PLUGIN_ROOT || path.resolve(moduleDirectory, "../.."));
14
+ const packaged = path.join(pluginRoot, "devflow-intel", "skills");
15
+ if (!existsSync(path.join(packaged, "workflow-code-index/scripts/code_index.py"))) {
16
+ throw new Error("Embedded Devflow Pi intel skills are missing");
17
+ }
18
+ return packaged;
19
+ }
12
20
  const candidates = [
13
21
  process.env.DEVFLOW_KIT_ROOT,
14
22
  path.join(cwd, ".skills"),
@@ -57,12 +65,24 @@ async function run(command: string[], cwd: string): Promise<ProcessResult> {
57
65
  return { ok: exitCode === 0, exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
58
66
  }
59
67
 
68
+ const SYMBOL_ACTIONS = new Set(["query", "relations", "impact", "query-plan"]);
69
+
70
+ /** A successful query with no matches prints nothing; say so instead of "Process exited with 0". */
71
+ function emptyOutputMessage(result: ProcessResult, action: unknown): string {
72
+ if (!result.ok) return `命令执行失败,没有输出任何信息(退出码 ${result.exitCode})。`;
73
+ if (SYMBOL_ACTIONS.has(String(action))) {
74
+ return "代码索引中没有匹配的结果。符号名多为英文类名、函数名或文件路径:请换用英文标识符再查,或用 action=search-content 按源码文本检索。";
75
+ }
76
+ if (action === "search-content") return "源码中没有找到匹配的文本。";
77
+ return "命令执行成功,但没有返回任何结果。";
78
+ }
79
+
60
80
  function render(result: ProcessResult, details: Record<string, unknown>) {
61
81
  return {
62
82
  content: [
63
83
  {
64
84
  type: "text" as const,
65
- text: result.stdout || result.stderr || `Process exited with ${result.exitCode}`,
85
+ text: result.stdout || result.stderr || emptyOutputMessage(result, details.action),
66
86
  },
67
87
  ],
68
88
  details: { ...details, exitCode: result.exitCode, stderr: result.stderr || undefined },
@@ -186,13 +206,15 @@ export default function devflowIntel(pi: ExtensionAPI) {
186
206
  parameters: z.object({ args: z.array(z.string()).default([]) }),
187
207
  approval: "exec",
188
208
  async execute(_id, params, _signal, _onUpdate, ctx) {
189
- const skillRoot = resolveSkillRoot(ctx.cwd);
209
+ const projectRoot = resolveProjectRoot(ctx.cwd);
210
+ const skillRoot = resolveSkillRoot(projectRoot);
190
211
  const script = path.join(
191
212
  skillRoot,
192
213
  "workflow-context-sync/scripts/context_sync_cursor.py",
193
214
  );
194
- return render(await run(["python3", script, ...params.args], ctx.cwd), {
215
+ return render(await run(["python3", script, ...params.args], projectRoot), {
195
216
  action: "context-cursor",
217
+ projectRoot,
196
218
  });
197
219
  },
198
220
  });